From 62e0fe94ef99d564a3b0ba9814367de8568c4909 Mon Sep 17 00:00:00 2001 From: menghao Date: Wed, 5 Aug 2026 19:15:46 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8D=A1=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=85=B3=E7=B3=BB=E5=8F=8A=E7=B1=BB=E5=9E=8B=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E9=A2=84=E8=A7=88=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完成资源卡按类型和按依赖分类展现的功能 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/129 Co-authored-by: menghao Co-committed-by: menghao --- .gitea/workflows/project-ci.yml | 6 +- .../smoke-agent-run-local-provider.mjs | 4 + .../src/agent/generation/canvas_generation.rs | 4 +- .../generation/external_generation_state.rs | 4 +- .../pending_confirmation_ledger.rs | 2 +- .../provider_request_builders.rs | 7 +- .../src-tauri/src/agent/runtime_driver.rs | 18 +- .../src/agent/runtime_driver/entrypoints.rs | 170 +- .../main_loop_deadline_tests.rs | 20 +- .../agent/runtime_driver/main_loop_tests.rs | 4 +- .../agent/runtime_driver/pending_execution.rs | 2 - .../agent/runtime_driver/pending_recovery.rs | 8 +- .../src/agent/runtime_driver/recovery_scan.rs | 2 +- .../autonomous_completion_contract_tests.rs | 4 +- .../agent/runtime_protocol/context_bundle.rs | 5 +- .../src-tauri/src/commands.rs | 71 + .../src-tauri/src/image_inspect.rs | 15 +- .../src-tauri/src/main.rs | 38 +- .../src-tauri/src/mcp.rs | 5 +- .../src-tauri/src/project.rs | 2 + .../src-tauri/src/project/manifest.rs | 205 +- .../src/project/manifest/recovery_tests.rs | 123 ++ .../src/project/resource_dependency_graph.rs | 1023 ++++++++++ .../src-tauri/src/resource_inspect.rs | 441 +++++ .../src-tauri/src/runner/client.rs | 35 +- .../src-tauri/src/runner/dispatch.rs | 20 +- .../src-tauri/src/runner/protocol.rs | 6 +- .../src-tauri/src/runner/tests.rs | 158 +- .../src/tests/collaboration/recovery.rs | 7 + .../src-tauri/src/tests/mod.rs | 158 +- .../src-tauri/src/tests/project_tools.rs | 104 + .../src-tauri/src/tests/runtime_state.rs | 22 +- apps/ai-game-creator-shell/src/App.tsx | 154 +- apps/ai-game-creator-shell/src/app/types.ts | 6 + .../features/app-shell/WorkspaceLauncher.tsx | 22 +- .../src/features/app-shell/model.ts | 4 + .../app-shell/useHomeProjectCreation.ts | 1 + apps/ai-game-creator-shell/src/styles.css | 284 ++- .../ResourceDependencyOverlay.tsx | 676 +++++++ .../src/view/project-development/index.tsx | 1683 ++++++++++------- .../resourceDependencyGraphModel.ts | 265 +++ .../resourceProjectionModel.ts | 278 +++ .../useProjectResourceCanvasLayout.ts | 129 +- .../tests/ResourceDependencyOverlay.test.ts | 517 +++++ .../tests/appSurface/harness.ts | 25 + .../tests/appSurface/home.suite.ts | 474 ++++- .../appSurface/project-development.suite.ts | 1515 ++++++++++++--- .../projectResourceProjectionModel.test.ts | 217 +++ .../resourceDependencyGraphModel.test.ts | 262 +++ .../useProjectResourceCanvasLayout.test.ts | 193 +- apps/desktop-shell/scripts/check-config.mjs | 16 + .../scripts/stage-release-binary.mjs | 8 +- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 147 +- .../shared-memory/decision-log.md | 82 +- .../shared-memory/development-workflow.md | 8 +- docs/project-memory/shared-memory/pitfalls.md | 74 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 60 +- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 10 +- .../src/contracts/gameCreationApp.test.ts | 20 + .../shared/src/contracts/gameCreationApp.ts | 20 + scripts/check-maintenance-page.mjs | 80 + scripts/check-module-runtime-artifact.mjs | 3 +- scripts/check-native-shells.mjs | 8 +- scripts/check-production-api-deploy.mjs | 60 +- scripts/deploy/maintenance-on.sh | 35 +- scripts/deploy/production-api-deploy.sh | 12 +- scripts/project-ci-workflow.test.ts | 36 + ...acetime-repair-editor-canvas-resources.mjs | 20 +- .../shared-contracts/src/game_creation_app.rs | 241 ++- .../image-editor/ImageCanvasEditorView.tsx | 3 + ...sePlatformProfileCenterController.test.tsx | 82 + .../usePlatformProfileCenterController.ts | 30 +- 73 files changed, 9237 insertions(+), 1218 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs create mode 100644 apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts create mode 100644 apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts create mode 100644 apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts create mode 100644 scripts/project-ci-workflow.test.ts create mode 100644 src/components/platform-entry/usePlatformProfileCenterController.test.tsx diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 4bdbb49cd..e19b10e7e 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -161,9 +161,6 @@ jobs: - name: Install npm dependencies run: npm ci - - name: Check server-rs boundaries - run: npm run check:server-rs-ddd - - name: Prepare server-rs Rust dependencies shell: bash run: | @@ -181,6 +178,9 @@ jobs: sleep $((attempt * 2)) done + - name: Check server-rs boundaries + run: npm run check:server-rs-ddd + - name: Run server-rs workspace tests run: cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml diff --git a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs index ad77d7f19..6b03ae39c 100644 --- a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs @@ -885,6 +885,10 @@ function readBrowserDom(url) { function resolveChromeBin() { for (const candidate of [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/opt/google/chrome/chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 4cc52b685..5c9613bdd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -5708,7 +5708,7 @@ mod canvas_generation_tests { fn read_test_http_request(stream: &mut std::net::TcpStream) -> String { stream - .set_read_timeout(Some(Duration::from_secs(2))) + .set_read_timeout(Some(Duration::from_secs(10))) .expect("set request read timeout"); let mut bytes = Vec::new(); let mut buffer = [0_u8; 4096]; @@ -6702,7 +6702,7 @@ mod canvas_generation_tests { #[tokio::test] async fn recovery_scan_resumes_accepted_generation_on_default_worker_stack() { - let temporary = tempfile::tempdir().expect("create accepted scan project"); + let temporary = crate::tests::canonical_test_tempdir("accepted-generation-scan-"); let root = temporary.path(); init_local_game_project_at(root, "accepted-scan", "恢复扫描测试") .expect("init accepted scan project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 7b13a6176..0de156f4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -838,7 +838,7 @@ mod external_generation_state_tests { #[test] fn prepared_generation_state_reuses_identity_and_transitions_to_accepted() { - let temporary = tempfile::tempdir().expect("create generation ledger project"); + let temporary = crate::tests::canonical_test_tempdir("external-generation-ledger-"); let root = temporary.path(); init_local_game_project_at(root, "generation-ledger", "生成账本测试") .expect("init project"); @@ -955,7 +955,7 @@ mod external_generation_state_tests { #[test] fn legacy_completed_generation_persists_only_allowlisted_safe_download_fields() { - let temporary = tempfile::tempdir().expect("create legacy generation ledger project"); + let temporary = crate::tests::canonical_test_tempdir("legacy-generation-ledger-"); let root = temporary.path(); init_local_game_project_at(root, "legacy-generation-ledger", "旧同步生成账本测试") .expect("init project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index fa5493585..6de6a816e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -790,7 +790,7 @@ mod tests { #[test] fn generation_cleanup_failure_preserves_pending_identity_anchor() { - let temporary = tempfile::tempdir().expect("create pending cleanup project"); + let temporary = crate::tests::canonical_test_tempdir("pending-generation-cleanup-"); let root = temporary.path(); let run_id = "generation-cleanup-order-run"; init_local_game_project_at(root, "generation-cleanup-order", "生成账本清理顺序测试") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 3b711d32c..756cda481 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -463,7 +463,8 @@ mod tests { root_source: &str, suffix: &str, ) -> String { - let temporary = tempfile::tempdir().expect("temporary project root"); + let temporary = + crate::tests::canonical_test_tempdir(&format!("provider-role-overlay-{suffix}-")); let root = temporary.path().join("project"); init_local_game_project_at(&root, &format!("overlay-{suffix}"), "role overlay test") .expect("project init"); @@ -549,7 +550,7 @@ mod tests { const ORDINARY_NOTICE: &str = "除下方有界仓库启动上下文、当前 Session 未压缩对话尾部或历史压缩摘要外,项目记忆、资产和源码正文不会预加载"; const MEMORY_MARKER: &str = "supervisor-preloaded-context-marker"; - let directory = tempfile::tempdir().expect("temp project directory"); + let directory = crate::tests::canonical_test_tempdir("provider-request-project-"); let root = directory.path().join("project"); init_local_game_project_at(&root, "project-1", "项目总控预加载说明测试") .expect("project init"); @@ -806,7 +807,7 @@ mod tests { #[test] fn planning_request_advertises_only_native_mcp_functions() { - let directory = tempfile::tempdir().expect("temp project directory"); + let directory = crate::tests::canonical_test_tempdir("native-mcp-prompt-"); let root = directory.path().join("project"); init_local_game_project_at(&root, "project-mcp", "MCP 原生函数说明测试") .expect("project init"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index bb36a99d4..b35413714 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -2,6 +2,12 @@ use super::*; pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock = OnceLock::new(); +pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock< + std::sync::Mutex>, +> = OnceLock::new(); +#[cfg(test)] +pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> = + std::sync::Mutex::new(()); pub(super) static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock< std::sync::Mutex>, > = OnceLock::new(); @@ -234,15 +240,21 @@ pub(in crate::agent) use recovery_scan::*; pub(in crate::agent) use task_queue::*; pub(in crate::agent) use task_start::*; +#[cfg(test)] +pub(crate) use entrypoints::acquire_game_creator_manifest_invalidation_event_sink_test_guard; #[allow(unused_imports)] pub(crate) use entrypoints::{ chat_with_game_creator_agent_at, chat_with_game_creator_role_agent_at, chat_with_game_creator_role_agent_for_session_at, chat_with_game_creator_role_agent_runtime_at, chat_with_game_creator_role_agent_runtime_for_session_at, chat_with_game_creator_role_agent_stream_at, - chat_with_game_creator_role_agent_stream_for_session_at, generate_local_game_draft_at, - read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, - read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle, + chat_with_game_creator_role_agent_stream_for_session_at, + configure_game_creator_manifest_invalidation_event_sink, + emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event, + generate_local_game_draft_at, read_game_creator_agent_runtime_at, + read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, + set_game_creator_agent_runtime_update_app_handle, + start_game_creator_manifest_invalidation_event_sink, }; #[cfg(test)] pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 29e951e8d..195bcd2eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -1,30 +1,174 @@ use super::*; +const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; + +fn lock_game_creator_manifest_invalidation_event_sink( +) -> std::sync::MutexGuard<'static, Option> { + GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) { let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app); } -pub(in crate::agent) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) { +pub(crate) fn start_game_creator_manifest_invalidation_event_sink( + app: tauri::AppHandle, +) -> Result { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .map_err(|error| format!("绑定 manifest 失效事件接收端失败:{error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("读取 manifest 失效事件接收端失败:{error}"))? + .port(); + let token = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + let expected_token = token.clone(); + thread::Builder::new() + .name("manifest-invalidation-event-sink".to_string()) + .spawn(move || { + for incoming in listener.incoming() { + let Ok(mut stream) = incoming else { + continue; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); + let mut payload = Vec::new(); + let mut limited = + (&mut stream).take(GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES + 1); + if limited.read_to_end(&mut payload).is_err() + || payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES + { + continue; + } + let Ok(envelope) = serde_json::from_slice::< + GameCreatorManifestInvalidationRelayEnvelope, + >(&payload) else { + continue; + }; + if envelope.token != expected_token { + continue; + } + let _ = app.emit("game-creator-manifest-invalidated", envelope.event); + } + }) + .map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?; + Ok(GameCreatorManifestInvalidationEventSink { port, token }) +} + +pub(crate) fn configure_game_creator_manifest_invalidation_event_sink( + port: u16, + token: &str, +) -> Result<(), String> { + if port == 0 { + return Err("manifest 失效事件接收端口无效".to_string()); + } + let token = token.trim(); + if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("manifest 失效事件接收令牌无效".to_string()); + } + *lock_game_creator_manifest_invalidation_event_sink() = + Some(GameCreatorManifestInvalidationEventSink { + port, + token: token.to_string(), + }); + Ok(()) +} + +#[cfg(test)] +pub(crate) struct GameCreatorManifestInvalidationEventSinkTestGuard { + _isolation: std::sync::MutexGuard<'static, ()>, +} + +#[cfg(test)] +impl GameCreatorManifestInvalidationEventSinkTestGuard { + pub(crate) fn configure(&self, port: u16, token: &str) -> Result<(), String> { + configure_game_creator_manifest_invalidation_event_sink(port, token) + } + + pub(crate) fn configured_sink(&self) -> Option { + lock_game_creator_manifest_invalidation_event_sink().clone() + } +} + +#[cfg(test)] +impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard { + fn drop(&mut self) { + *lock_game_creator_manifest_invalidation_event_sink() = None; + } +} + +#[cfg(test)] +pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard( +) -> GameCreatorManifestInvalidationEventSinkTestGuard { + let isolation = GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + GameCreatorManifestInvalidationEventSinkTestGuard { + _isolation: isolation, + } +} + +fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> { + let sink = lock_game_creator_manifest_invalidation_event_sink().clone(); + let Some(sink) = sink else { + return Ok(()); + }; + let envelope = GameCreatorManifestInvalidationRelayEnvelope { + token: sink.token, + event: GameCreatorManifestInvalidatedEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: agent_id.to_string(), + }, + }; + let payload = serde_json::to_vec(&envelope) + .map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?; + if payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES { + return Err("manifest 失效事件超过大小上限".to_string()); + } + let address = std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, sink.port).into(); + let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(100)) + .map_err(|error| format!("连接 manifest 失效事件接收端失败:{error}"))?; + stream + .set_write_timeout(Some(Duration::from_millis(100))) + .map_err(|error| format!("配置 manifest 失效事件发送超时失败:{error}"))?; + stream + .write_all(&payload) + .map_err(|error| format!("发送 manifest 失效事件失败:{error}")) +} + +pub(crate) fn game_creator_agent_runtime_update_event( + root: &Path, + runtime: AgentRuntimeResult, +) -> GameCreatorAgentRuntimeUpdateEvent { + GameCreatorAgentRuntimeUpdateEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: runtime.state.agent_id.clone(), + run_id: runtime.state.run_id.clone(), + status: runtime.state.status.clone(), + phase: runtime.state.phase.clone(), + manifest_invalidated: true, + runtime, + } +} + +pub(crate) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) { + if GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get().is_none() { + let _ = relay_game_creator_manifest_invalidation(root, agent_id); + } let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; let Ok(runtime) = read_game_creator_agent_runtime_at(root, agent_id) else { return; }; - let agent_id = runtime.state.agent_id.clone(); - let run_id = runtime.state.run_id.clone(); - let status = runtime.state.status.clone(); - let phase = runtime.state.phase.clone(); let _ = app.emit( "game-creator-agent-runtime-update", - GameCreatorAgentRuntimeUpdateEvent { - project_path: root.to_string_lossy().into_owned(), - agent_id, - run_id, - status, - phase, - runtime, - }, + game_creator_agent_runtime_update_event(root, runtime), ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs index 3033d5a20..84866b305 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs @@ -64,14 +64,8 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry() #[tokio::test] async fn game_chat_absolute_deadline_preserves_external_generation_for_same_action_resume() { - let root = std::env::temp_dir().join(format!( - "genarrative-game-chat-deadline-reconciliation-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock") - .as_nanos() - )); + let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-reconciliation-"); + let root = temporary.path().join("project"); init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试") .expect("project init"); bind_game_creator_agent_runtime_run_profile_at( @@ -239,14 +233,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_for_same_acti #[test] fn game_chat_absolute_deadline_still_cleans_local_action_recovery() { - let root = std::env::temp_dir().join(format!( - "genarrative-game-chat-deadline-local-cleanup-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock") - .as_nanos() - )); + let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-local-cleanup-"); + let root = temporary.path().join("project"); init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试") .expect("project init"); let mut runtime = start_game_creator_agent_runtime_task_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 5f1996351..67c7400c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -1207,7 +1207,7 @@ fn standard_specialist_empty_plan_has_no_deterministic_final_reply_fallback() { fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() { const RUN_ID: &str = "autonomous-manifest-waiting-parent"; const TASK: &str = "生成完整小游戏并完成项目任务图"; - let temporary = tempfile::tempdir().expect("create manifest waiting root"); + let temporary = crate::tests::canonical_test_tempdir("manifest-waiting-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "manifest-waiting-project", TASK) .expect("init manifest waiting project"); @@ -1421,7 +1421,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac const TASK: &str = "生成一个可完成静态检查和双视口试玩的塔防游戏"; const TEST_KEY: &str = "autonomous-final-reply-fallback-key"; - let temporary = tempfile::tempdir().expect("create autonomous fallback root"); + let temporary = crate::tests::canonical_test_tempdir("autonomous-fallback-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "autonomous-fallback-project", TASK) .expect("init autonomous fallback project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs index e7370c78f..0aa6585e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs @@ -13,7 +13,6 @@ where .await .expect("pending continuation task must exist") } - async fn run_after_pending_stack_boundary( future: std::pin::Pin + Send + 'static>>, ) -> T @@ -150,7 +149,6 @@ fn persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at( ); emit_game_creator_agent_runtime_update(root, &runtime.agent_id); } - pub(crate) async fn continue_game_creator_agent_pending_tool_action( root: PathBuf, agent_id: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 246cc05a1..1bc5adaa9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -1332,7 +1332,7 @@ mod pending_recovery_tests { #[test] fn observed_unknown_canvas_generation_returns_to_same_approved_action() { - let temporary = tempfile::tempdir().expect("create prepared pending project"); + let temporary = crate::tests::canonical_test_tempdir("prepared-pending-"); let root = temporary.path(); init_local_game_project_at(root, "prepared-pending", "原俄罗斯方块项目") .expect("init prepared pending project"); @@ -1413,7 +1413,7 @@ mod pending_recovery_tests { #[test] fn legacy_executing_canvas_generation_returns_to_same_approved_action() { - let temporary = tempfile::tempdir().expect("create executing prepared project"); + let temporary = crate::tests::canonical_test_tempdir("executing-prepared-"); let root = temporary.path(); init_local_game_project_at(root, "executing-prepared", "旧版俄罗斯方块项目") .expect("init executing prepared project"); @@ -1490,7 +1490,7 @@ mod pending_recovery_tests { #[test] fn observed_postprocessing_failure_resumes_from_accepted_generation() { - let temporary = tempfile::tempdir().expect("create accepted recovery project"); + let temporary = crate::tests::canonical_test_tempdir("accepted-recovery-"); let root = temporary.path(); init_local_game_project_at(root, "accepted-recovery", "俄罗斯方块素材后处理恢复") .expect("init accepted recovery project"); @@ -1560,7 +1560,7 @@ mod pending_recovery_tests { #[test] fn canvas_reconciliation_keeps_the_context_plan_step_active() { - let temporary = tempfile::tempdir().expect("create reconciliation context project"); + let temporary = crate::tests::canonical_test_tempdir("reconciliation-context-"); let root = temporary.path(); init_local_game_project_at(root, "reconciliation-context", "俄罗斯方块恢复上下文") .expect("init reconciliation context project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index 974216689..364eca9cc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -1221,7 +1221,7 @@ mod orphaned_external_generation_recovery_tests { #[test] fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() { - let temporary = tempfile::tempdir().expect("create orphan generation recovery project"); + let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-"); let root = temporary.path(); let run_id = "orphan-generation-recovery-run"; init_local_game_project_at(root, "orphan-generation-recovery", "孤儿生成账本恢复测试") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 2cd3b3af9..700ad713a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -22,7 +22,7 @@ fn autonomous_fixture_with_source( AgentRuntimeState, AgentRuntimeAutonomousCompletionContract, ) { - let temporary = tempfile::tempdir().expect("create autonomous fixture root"); + let temporary = crate::tests::canonical_test_tempdir("autonomous-fixture-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "autonomous-project", task).expect("init project"); let session_id = resolve_agent_conversation_session_id_at( @@ -223,7 +223,7 @@ fn autonomous_fixture_with_setup( AgentRuntimeState, AgentRuntimeAutonomousCompletionContract, ) { - let temporary = tempfile::tempdir().expect("create autonomous fixture root"); + let temporary = crate::tests::canonical_test_tempdir("autonomous-setup-fixture-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "autonomous-project", task).expect("init project"); setup(&root); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index b9ed6e360..9f2187da8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -826,10 +826,11 @@ mod tests { .duration_since(UNIX_EPOCH) .expect("system clock should be after epoch") .as_nanos(); - let root = std::env::temp_dir().join(format!( - "genarrative-context-window-boundary-{}-{unique}", + let temporary = crate::tests::canonical_test_tempdir(&format!( + "context-window-boundary-{}-{unique}-", std::process::id() )); + let root = temporary.path().join("project"); init_local_game_project_at(&root, "project-1", "上下文窗口边界恢复项目") .expect("project init"); let mut runtime = start_game_creator_agent_runtime_task_at( 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 5c9f77367..bff274d4b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -204,6 +204,17 @@ pub(crate) fn read_local_project_resource_canvas_layout( read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode) } +#[tauri::command] +pub(crate) fn read_local_project_resource_graph( + project_path: String, + expected_project_id: String, + resources: Vec, +) -> Result { + let root = validated_local_project_directory_path(project_path.trim())?; + enforce_project_auto_permission_policy(&root, "asset.list")?; + read_project_resource_graph_at(&root, expected_project_id.trim(), resources) +} + #[tauri::command] pub(crate) fn update_local_project_resource_canvas_layout( project_path: String, @@ -1166,6 +1177,66 @@ pub(crate) fn read_local_project_image_preview( load_local_project_image_preview(root, &normalized_path) } +#[tauri::command] +pub(crate) fn read_local_project_text_preview( + project_path: String, + relative_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + let is_registered_document = manifest.assets.iter().any(|asset| { + asset.local_path == normalized_path + && is_supported_project_text_resource(&asset.local_path, &asset.media_type) + }) || manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_text_resource(&normalized_path, "") + }); + if !is_registered_document { + return Err("只能读取当前项目已登记的文档资源".to_string()); + } + load_local_project_text_preview(root, &normalized_path) +} + +#[tauri::command] +pub(crate) fn read_local_project_media_preview( + project_path: String, + relative_path: String, + category: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + let kind = match category.trim() { + "art" => ProjectMediaPreviewKind::Art, + "audio" => ProjectMediaPreviewKind::Audio, + _ => return Err("媒体预览类别只支持 art 或 audio".to_string()), + }; + let is_registered_media = manifest.assets.iter().any(|asset| { + asset.local_path == normalized_path + && match kind { + ProjectMediaPreviewKind::Art => { + is_supported_project_art_media_resource(&asset.local_path, &asset.media_type) + } + ProjectMediaPreviewKind::Audio => { + is_supported_project_audio_resource(&asset.local_path, &asset.media_type) + } + } + }) || (kind == ProjectMediaPreviewKind::Art + && manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_art_media_resource(&normalized_path, "") + })); + if !is_registered_media { + return Err("只能预览当前项目已登记的媒体资源".to_string()); + } + load_local_project_media_preview(root, &normalized_path, kind) +} + #[tauri::command] pub(crate) fn write_local_project_file( project_path: String, 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 6ab5a5979..5d6362ba5 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 @@ -204,7 +204,10 @@ fn validate_agent_runtime_inspection_path( Ok(()) } -fn validate_agent_runtime_inspection_ancestors(root: &Path, path: &Path) -> Result<(), String> { +pub(crate) fn validate_agent_runtime_inspection_ancestors( + root: &Path, + path: &Path, +) -> Result<(), String> { let relative = path .strip_prefix(root) .map_err(|_| "image.inspect 图片路径超出项目目录".to_string())?; @@ -450,7 +453,7 @@ fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool { } #[cfg(unix)] -fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { +pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { use std::os::unix::fs::MetadataExt; left.dev() == right.dev() && left.ino() == right.ino() @@ -463,12 +466,12 @@ fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { } #[cfg(not(unix))] -fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { +pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { left.len() == right.len() && left.modified().ok() == right.modified().ok() } #[cfg(unix)] -fn same_open_file_identity( +pub(crate) fn same_open_file_identity( _left_file: &fs::File, left: &fs::Metadata, _right_file: &fs::File, @@ -479,7 +482,7 @@ fn same_open_file_identity( } #[cfg(windows)] -fn same_open_file_identity( +pub(crate) fn same_open_file_identity( left_file: &fs::File, _left: &fs::Metadata, right_file: &fs::File, @@ -489,7 +492,7 @@ fn same_open_file_identity( } #[cfg(not(any(unix, windows)))] -fn same_open_file_identity( +pub(crate) fn same_open_file_identity( _left_file: &fs::File, left: &fs::Metadata, _right_file: &fs::File, 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 d0f317a6b..60d8094a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -23,8 +23,9 @@ use reqwest::header; use serde::{Deserialize, Serialize}; use shared_contracts::game_creation_app::{ new_game_creation_app_manifest, new_game_creation_app_seed_tasks, - GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor, - GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, + validate_game_iteration_versions, GameCreationAgentArtifactTrace, + GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace, + GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace, GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState, @@ -72,6 +73,7 @@ mod project; mod provider_handoff; mod provider_retry; mod repository_context; +mod resource_inspect; mod runner; mod swarm_cli; mod tool_plan_handoff; @@ -101,6 +103,7 @@ use preview::*; use process_session::*; use project::*; use repository_context::*; +use resource_inspect::*; use runner::*; use swarm_cli::*; use user_input::*; @@ -626,9 +629,30 @@ struct GameCreatorAgentRuntimeUpdateEvent { run_id: String, status: String, phase: String, + manifest_invalidated: bool, runtime: AgentRuntimeResult, } +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorManifestInvalidatedEvent { + project_path: String, + agent_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorManifestInvalidationRelayEnvelope { + token: String, + event: GameCreatorManifestInvalidatedEvent, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct GameCreatorManifestInvalidationEventSink { + port: u16, + token: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorAgentProgressEvent { @@ -1848,6 +1872,7 @@ mod game_chat_release_client_exit_tests { } } +#[cfg(not(test))] fn main() { let mut args = std::env::args().skip(1).collect::>(); #[cfg(target_os = "linux")] @@ -2088,7 +2113,10 @@ fn main() { format!("启动 Agent Runner 失败:{error}"), ) })?; - attach_external_agent_runner_gui_owner() + set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); + let manifest_event_sink = + start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; + attach_external_agent_runner_gui_owner(&manifest_event_sink) .inspect_err(|error| { if let Some(path) = setup_log.as_deref() { let details = @@ -2109,7 +2137,6 @@ fn main() { if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete"); } - set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); #[cfg(all(debug_assertions, not(test)))] if game_chat_launch.is_none() { open_developer_window(app.handle())?; @@ -2170,6 +2197,8 @@ fn main() { list_local_project_files, read_local_project_file, read_local_project_image_preview, + read_local_project_text_preview, + read_local_project_media_preview, write_local_project_file, delete_local_project_file, read_local_game_memory, @@ -2201,6 +2230,7 @@ fn main() { stop_local_game_preview_if_matches, get_local_game_preview_status, read_local_project_resource_canvas_layout, + read_local_project_resource_graph, update_local_project_resource_canvas_layout, get_local_game_project_revision, get_local_game_manifest diff --git a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs index b4d79f7f9..28684775e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs @@ -1980,7 +1980,10 @@ mod tests { } fn mcp_test_project(label: &str) -> PathBuf { - let root = std::env::temp_dir().join(format!( + let temp_root = std::env::temp_dir() + .canonicalize() + .expect("canonicalize MCP test temp root"); + let root = temp_root.join(format!( "game-creator-mcp-{label}-{}-{}", std::process::id(), MCP_TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index aee2ea42a..1099d78e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -10,6 +10,7 @@ mod export; mod filesystem; mod manifest; mod memory; +mod resource_dependency_graph; mod resource_layout; mod verification; @@ -20,5 +21,6 @@ pub(crate) use export::*; pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; +pub(crate) use resource_dependency_graph::*; pub(crate) use resource_layout::*; pub(crate) use verification::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 91fb6a9ff..80216aa36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1,5 +1,169 @@ use super::*; +const MANIFEST_LOCK_WAIT_ATTEMPTS: usize = 500; +const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10; + +static MANIFEST_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +struct ManifestWriteLock { + _file: File, +} + +fn manifest_lock_path(path: &Path) -> PathBuf { + path.with_file_name(format!( + ".{}.lock", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("manifest.json") + )) +} + +fn acquire_manifest_write_lock(path: &Path) -> Result { + for attempt in 0..MANIFEST_LOCK_WAIT_ATTEMPTS { + if let Some(file) = try_open_manifest_write_lock_file(path)? { + return Ok(ManifestWriteLock { _file: file }); + } + if attempt + 1 < MANIFEST_LOCK_WAIT_ATTEMPTS { + std::thread::sleep(Duration::from_millis(MANIFEST_LOCK_WAIT_MILLIS)); + } + } + Err("manifest 正在被其他进程写入,请稍后重试".to_string()) +} + +#[cfg(unix)] +fn try_open_manifest_write_lock_file(path: &Path) -> Result, String> { + use std::os::fd::AsRawFd; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + + let _open_guard = MANIFEST_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?; + let lock_path = manifest_lock_path(path); + let mut options = fs::OpenOptions::new(); + options + .create(true) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); + let file = options + .open(&lock_path) + .map_err(|error| format!("安全打开 manifest 锁失败:{}: {error}", lock_path.display()))?; + let metadata = file.metadata().map_err(|error| { + format!( + "读取 manifest 锁句柄元数据失败:{}: {error}", + lock_path.display() + ) + })?; + // SAFETY: geteuid takes no arguments and has no memory safety preconditions. + let effective_user_id = unsafe { libc::geteuid() }; + if !metadata.is_file() || metadata.uid() != effective_user_id || metadata.nlink() != 1 { + return Err(format!( + "manifest 锁必须是当前用户持有的无硬链接普通文件:{}", + lock_path.display() + )); + } + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("收紧 manifest 锁权限失败:{}: {error}", lock_path.display()))?; + let path_metadata = fs::symlink_metadata(&lock_path) + .map_err(|error| format!("复核 manifest 锁路径失败:{}: {error}", lock_path.display()))?; + if path_metadata.file_type().is_symlink() + || path_metadata.dev() != metadata.dev() + || path_metadata.ino() != metadata.ino() + { + return Err(format!( + "manifest 锁路径在安全打开期间发生替换:{}", + lock_path.display() + )); + } + let verified = file + .metadata() + .map_err(|error| format!("复核 manifest 锁句柄失败:{}: {error}", lock_path.display()))?; + if verified.uid() != effective_user_id + || verified.nlink() != 1 + || verified.permissions().mode() & 0o777 != 0o600 + { + return Err(format!( + "manifest 锁必须由当前用户持有且权限为 0600:{}", + lock_path.display() + )); + } + // SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(Some(file)); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(format!( + "获取 manifest 系统文件锁失败:{}: {error}", + lock_path.display() + )) + } +} + +#[cfg(windows)] +fn try_open_manifest_write_lock_file(path: &Path) -> Result, String> { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + + let _open_guard = MANIFEST_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?; + let lock_path = manifest_lock_path(path); + if let Ok(metadata) = fs::symlink_metadata(&lock_path) { + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(format!( + "manifest 锁必须是普通文件且不能是 reparse point:{}", + lock_path.display() + )); + } + } + match fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&lock_path) + { + Ok(file) => { + validate_windows_regular_file_handle(&file, "manifest 锁")?; + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, true)?; + Ok(Some(file)) + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) => + { + Ok(None) + } + Err(error) => Err(format!( + "获取 manifest 系统文件锁失败:{}: {error}", + lock_path.display() + )), + } +} + +#[cfg(not(any(unix, windows)))] +fn try_open_manifest_write_lock_file(path: &Path) -> Result, String> { + Err(format!( + "当前平台不支持 manifest 系统文件锁:{}", + manifest_lock_path(path).display() + )) +} + pub(crate) fn init_local_game_project_at( root: &Path, project_id: &str, @@ -631,8 +795,11 @@ pub(crate) fn read_manifest(path: &Path) -> Result( @@ -709,12 +876,39 @@ pub(crate) fn write_manifest( path: &Path, manifest: &GameCreationAppManifest, ) -> Result<(), String> { + write_manifest_with_lock_hook(path, manifest, || {}) +} + +fn write_manifest_with_lock_hook( + path: &Path, + manifest: &GameCreationAppManifest, + after_lock: F, +) -> Result<(), String> +where + F: FnOnce(), +{ + validate_game_iteration_versions(&manifest.versions) + .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; let payload = serde_json::to_string_pretty(manifest) .map_err(|error| format!("序列化 manifest 失败:{error}"))?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; } + let _write_lock = acquire_manifest_write_lock(path)?; + after_lock(); + if manifest_storage_exists(path)? { + let existing = read_manifest(path)?; + if existing.versions.len() > manifest.versions.len() + || existing + .versions + .iter() + .zip(&manifest.versions) + .any(|(existing, candidate)| existing != candidate) + { + return Err("项目版本记录写入后不可修改、删除或重排".to_string()); + } + } match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err("manifest 必须是普通文件".to_string()); @@ -745,7 +939,12 @@ pub(crate) fn write_manifest( temp_path.display() ) })?; - install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to)) + install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?; + let installed = read_manifest(path)?; + if installed != *manifest { + return Err("manifest 安装后回读与待写入内容不一致".to_string()); + } + Ok(()) } pub(crate) fn sanitize_file_name(file_name: &str) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index c82b0144d..cf4c331fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -1,4 +1,7 @@ use super::*; +use shared_contracts::game_creation_app::{ + GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding, +}; fn unique_manifest_test_root(test_name: &str) -> PathBuf { std::env::temp_dir().join(format!( @@ -40,6 +43,126 @@ fn manifest_read_and_project_write_recover_previous_file() { fs::remove_dir_all(root).ok(); } +fn version_fixture( + version_id: &str, + parent_version_id: Option<&str>, + project_revision: u64, + created_reason: GameIterationVersionCreatedReason, +) -> GameIterationVersion { + GameIterationVersion { + version_id: version_id.to_string(), + parent_version_id: parent_version_id.map(str::to_string), + project_revision, + resource_bindings: vec![GameIterationVersionResourceBinding { + slot_id: "player".to_string(), + resource_id: "asset-player".to_string(), + }], + created_reason, + created_at: project_revision, + } +} + +#[test] +fn manifest_versions_are_append_only_at_the_storage_boundary() { + let root = unique_manifest_test_root("versions-append-only"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = new_game_creation_app_manifest("project-versioned", "版本项目"); + manifest.versions.push(version_fixture( + "version-root", + None, + 1, + GameIterationVersionCreatedReason::Initial, + )); + write_manifest(&manifest_path, &manifest).expect("write initial version"); + + manifest.versions.push(version_fixture( + "version-child", + Some("version-root"), + 2, + GameIterationVersionCreatedReason::AgentRevision, + )); + write_manifest(&manifest_path, &manifest).expect("append child version"); + + let stable_payload = fs::read(&manifest_path).expect("read stable manifest bytes"); + manifest.versions[0].resource_bindings[0].resource_id = "asset-mutated".to_string(); + let error = + write_manifest(&manifest_path, &manifest).expect_err("reject mutation of existing version"); + assert!(error.contains("不可修改、删除或重排"), "{error}"); + assert_eq!( + fs::read(&manifest_path).expect("read untouched manifest bytes"), + stable_payload + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn concurrent_manifest_write_cannot_overwrite_an_installed_version_with_a_stale_snapshot() { + let root = unique_manifest_test_root("versions-concurrent-append-only"); + let manifest_path = root.join(".agent/manifest.json"); + let mut stale_manifest = new_game_creation_app_manifest("project-versioned", "并发版本项目"); + stale_manifest.versions.push(version_fixture( + "version-root", + None, + 1, + GameIterationVersionCreatedReason::Initial, + )); + write_manifest(&manifest_path, &stale_manifest).expect("write initial version"); + + let mut newer_manifest = stale_manifest.clone(); + newer_manifest.versions.push(version_fixture( + "version-child", + Some("version-root"), + 2, + GameIterationVersionCreatedReason::AgentRevision, + )); + let (newer_locked_tx, newer_locked_rx) = mpsc::channel(); + let (release_newer_tx, release_newer_rx) = mpsc::channel(); + let newer_path = manifest_path.clone(); + let newer_writer = std::thread::spawn(move || { + write_manifest_with_lock_hook(&newer_path, &newer_manifest, || { + newer_locked_tx + .send(()) + .expect("signal newer lock acquired"); + release_newer_rx.recv().expect("release newer writer"); + }) + }); + newer_locked_rx + .recv_timeout(Duration::from_secs(2)) + .expect("newer writer acquires manifest lock"); + + let (stale_started_tx, stale_started_rx) = mpsc::channel(); + let stale_path = manifest_path.clone(); + let stale_writer = std::thread::spawn(move || { + stale_started_tx + .send(()) + .expect("signal stale writer started"); + write_manifest(&stale_path, &stale_manifest) + }); + stale_started_rx + .recv_timeout(Duration::from_secs(2)) + .expect("stale writer starts while newer writer holds lock"); + release_newer_tx.send(()).expect("release newer writer"); + + newer_writer + .join() + .expect("join newer writer") + .expect("install newer manifest"); + let stale_error = stale_writer + .join() + .expect("join stale writer") + .expect_err("reject stale manifest after newer version is installed"); + assert!( + stale_error.contains("不可修改、删除或重排"), + "{stale_error}" + ); + let installed = read_manifest(&manifest_path).expect("read final manifest"); + assert_eq!(installed.versions.len(), 2); + assert_eq!(installed.versions[1].version_id, "version-child"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn manifest_install_uses_previous_when_direct_replace_fails() { let root = unique_manifest_test_root("replace-fallback"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs new file mode 100644 index 000000000..5a4a36fb1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -0,0 +1,1023 @@ +use super::*; +use std::collections::{BTreeMap, BTreeSet}; + +const RESOURCE_GRAPH_AGENT_DB_READ_BYTES: u64 = 32 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceGraphNodeInput { + pub resource_id: String, + #[serde(default)] + pub manifest_asset_id: Option, + #[serde(default)] + pub producer_task_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceReferenceEdge { + pub id: String, + pub kind: String, + pub source_resource_id: String, + pub target_resource_id: String, + pub cyclic: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceTaskFlow { + pub id: String, + pub kind: String, + pub source_task_id: String, + pub target_task_id: String, + pub source_resource_ids: Vec, + pub target_resource_ids: Vec, + pub cyclic: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceConnectionIndex { + pub resource_id: String, + pub upstream_reference_resource_ids: Vec, + pub downstream_reference_resource_ids: Vec, + pub reference_edge_ids: Vec, + pub task_flow_ids: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceProducerAssignment { + pub resource_id: String, + pub task_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceDependencyDepth { + pub resource_id: String, + pub dependency_depth: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceGraphReadModel { + pub resource_ids: Vec, + pub reference_edges: Vec, + pub task_flows: Vec, + pub connection_index: Vec, + pub producer_assignments: Vec, + pub dependency_depths: Vec, + pub unresolved_reference_resource_ids: Vec, + pub cyclic_resource_ids: Vec, + pub cyclic_task_ids: Vec, + pub producer_mapping_truncated: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DirectedEdge { + id: String, + source_id: String, + target_id: String, +} + +#[derive(Debug, Default)] +struct CycleAnalysis { + cyclic_node_ids: BTreeSet, + cyclic_edge_ids: BTreeSet, + component_by_node: BTreeMap, +} + +#[derive(Debug, Default)] +struct MutableConnectionIndex { + upstream_reference_resource_ids: BTreeSet, + downstream_reference_resource_ids: BTreeSet, + reference_edge_ids: BTreeSet, + task_flow_ids: BTreeSet, +} + +fn stable_edge_id(kind: &str, source_id: &str, target_id: &str) -> String { + let pair = serde_json::to_string(&(source_id, target_id)) + .expect("serializing two resource graph identifiers cannot fail"); + format!("{kind}:{pair}") +} + +fn analyze_directed_cycles<'a>( + node_ids: impl IntoIterator, + edges: &[DirectedEdge], +) -> CycleAnalysis { + let mut nodes = node_ids.into_iter().cloned().collect::>(); + for edge in edges { + nodes.insert(edge.source_id.clone()); + nodes.insert(edge.target_id.clone()); + } + + let mut adjacency = nodes + .iter() + .map(|node_id| (node_id.clone(), Vec::::new())) + .collect::>(); + let mut reverse_adjacency = adjacency.clone(); + for edge in edges { + adjacency + .entry(edge.source_id.clone()) + .or_default() + .push(edge.target_id.clone()); + reverse_adjacency + .entry(edge.target_id.clone()) + .or_default() + .push(edge.source_id.clone()); + } + + let mut visited = BTreeSet::new(); + let mut finish_order = Vec::with_capacity(nodes.len()); + for root in &nodes { + if !visited.insert(root.clone()) { + continue; + } + let mut stack = vec![(root.clone(), 0usize)]; + while let Some((node_id, next_index)) = stack.last_mut() { + let neighbors = adjacency.get(node_id).map(Vec::as_slice).unwrap_or(&[]); + if let Some(next) = neighbors.get(*next_index) { + *next_index += 1; + if visited.insert(next.clone()) { + stack.push((next.clone(), 0)); + } + } else { + let completed = node_id.clone(); + stack.pop(); + finish_order.push(completed); + } + } + } + + let mut component_by_node = BTreeMap::::new(); + let mut component_sizes = Vec::::new(); + for root in finish_order.into_iter().rev() { + if component_by_node.contains_key(&root) { + continue; + } + let component_id = component_sizes.len(); + let mut size = 0usize; + let mut stack = vec![root.clone()]; + component_by_node.insert(root, component_id); + while let Some(node_id) = stack.pop() { + size += 1; + for neighbor in reverse_adjacency + .get(&node_id) + .map(Vec::as_slice) + .unwrap_or(&[]) + { + if !component_by_node.contains_key(neighbor) { + component_by_node.insert(neighbor.clone(), component_id); + stack.push(neighbor.clone()); + } + } + } + component_sizes.push(size); + } + + let mut result = CycleAnalysis::default(); + for edge in edges { + let source_component = component_by_node.get(&edge.source_id); + let target_component = component_by_node.get(&edge.target_id); + if source_component.is_some() + && source_component == target_component + && (component_sizes + .get(source_component.copied().unwrap_or_default()) + .copied() + .unwrap_or_default() + > 1 + || edge.source_id == edge.target_id) + { + result.cyclic_node_ids.insert(edge.source_id.clone()); + result.cyclic_node_ids.insert(edge.target_id.clone()); + result.cyclic_edge_ids.insert(edge.id.clone()); + } + } + result.component_by_node = component_by_node; + result +} + +fn dependency_depth_by_node( + analysis: &CycleAnalysis, + edges: &[DirectedEdge], + minimum_depth_by_node: &BTreeMap, +) -> BTreeMap { + let component_count = analysis + .component_by_node + .values() + .copied() + .max() + .map_or(0, |max_component| max_component + 1); + let mut outgoing = vec![BTreeSet::::new(); component_count]; + let mut indegree = vec![0usize; component_count]; + for edge in edges { + let Some(&source_component) = analysis.component_by_node.get(&edge.source_id) else { + continue; + }; + let Some(&target_component) = analysis.component_by_node.get(&edge.target_id) else { + continue; + }; + if source_component != target_component + && outgoing[source_component].insert(target_component) + { + indegree[target_component] += 1; + } + } + + let mut ready = indegree + .iter() + .enumerate() + .filter_map(|(component, degree)| (*degree == 0).then_some(component)) + .collect::>(); + let mut depth_by_component = vec![0u32; component_count]; + for (node_id, minimum_depth) in minimum_depth_by_node { + let Some(component) = analysis.component_by_node.get(node_id) else { + continue; + }; + depth_by_component[*component] = depth_by_component[*component].max(*minimum_depth); + } + while let Some(component) = ready.pop_first() { + for &target in &outgoing[component] { + depth_by_component[target] = + depth_by_component[target].max(depth_by_component[component].saturating_add(1)); + indegree[target] -= 1; + if indegree[target] == 0 { + ready.insert(target); + } + } + } + + analysis + .component_by_node + .iter() + .map(|(node_id, component)| { + ( + node_id.clone(), + depth_by_component + .get(*component) + .copied() + .unwrap_or_default(), + ) + }) + .collect() +} + +fn audit_asset_producers( + records: &[serde_json::Value], + task_ids: &BTreeSet, +) -> BTreeMap { + let mut candidates = BTreeMap::>::new(); + for record in records { + if record.get("recordType").and_then(serde_json::Value::as_str) + != Some("agent.runtime.canvas.asset_generate") + { + continue; + } + let Some(asset_id) = record + .get("assetId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + let Some(agent_id) = record + .get("agentId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| task_ids.contains(*value)) + else { + continue; + }; + candidates + .entry(asset_id.to_string()) + .or_default() + .insert(agent_id.to_string()); + } + candidates + .into_iter() + .filter_map(|(asset_id, agents)| { + (agents.len() == 1).then(|| (asset_id, agents.into_iter().next().unwrap_or_default())) + }) + .collect() +} + +pub(crate) fn build_project_resource_graph( + manifest: &GameCreationAppManifest, + resources: Vec, + agent_db_records: &[serde_json::Value], + producer_mapping_truncated: bool, +) -> ProjectResourceGraphReadModel { + let resource_by_id = resources + .into_iter() + .filter_map(|mut resource| { + resource.resource_id = resource.resource_id.trim().to_string(); + (!resource.resource_id.is_empty()).then_some((resource.resource_id.clone(), resource)) + }) + .collect::>(); + let task_by_id = manifest + .tasks + .iter() + .map(|task| (task.id.clone(), task)) + .collect::>(); + let task_ids = task_by_id.keys().cloned().collect::>(); + let manifest_asset_by_id = manifest + .assets + .iter() + .map(|asset| (asset.id.clone(), asset)) + .collect::>(); + let audit_producer_by_asset_id = if producer_mapping_truncated { + BTreeMap::new() + } else { + audit_asset_producers(agent_db_records, &task_ids) + }; + + let mut resource_ids_by_manifest_asset = BTreeMap::>::new(); + for resource in resource_by_id.values() { + if let Some(asset_id) = resource + .manifest_asset_id + .as_deref() + .map(str::trim) + .filter(|asset_id| manifest_asset_by_id.contains_key(*asset_id)) + { + resource_ids_by_manifest_asset + .entry(asset_id.to_string()) + .or_default() + .push(resource.resource_id.clone()); + } + } + + let mut producer_by_resource_id = BTreeMap::::new(); + for resource in resource_by_id.values() { + let producer = if let Some(asset_id) = resource + .manifest_asset_id + .as_deref() + .map(str::trim) + .filter(|asset_id| { + resource_ids_by_manifest_asset + .get(*asset_id) + .is_some_and(|resource_ids| resource_ids.len() == 1) + }) { + audit_producer_by_asset_id.get(asset_id).cloned() + } else { + resource + .producer_task_id + .as_deref() + .map(str::trim) + .filter(|task_id| task_ids.contains(*task_id)) + .map(ToOwned::to_owned) + }; + if let Some(producer) = producer { + producer_by_resource_id.insert(resource.resource_id.clone(), producer); + } + } + + let mut resources_by_task = BTreeMap::>::new(); + for (resource_id, task_id) in &producer_by_resource_id { + resources_by_task + .entry(task_id.clone()) + .or_default() + .push(resource_id.clone()); + } + + let mut resources_by_external_id = BTreeMap::>::new(); + for (asset_id, resource_ids) in &resource_ids_by_manifest_asset { + if resource_ids.len() != 1 { + continue; + } + let Some(external_resource_id) = manifest_asset_by_id + .get(asset_id) + .and_then(|asset| asset.source.resource_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + resources_by_external_id + .entry(external_resource_id.to_string()) + .or_default() + .push(resource_ids[0].clone()); + } + + let mut unresolved_reference_resource_ids = BTreeSet::new(); + let mut reference_edge_by_id = BTreeMap::::new(); + for (asset_id, target_resource_ids) in &resource_ids_by_manifest_asset { + if target_resource_ids.len() != 1 { + continue; + } + let Some(asset) = manifest_asset_by_id.get(asset_id) else { + continue; + }; + let target_resource_id = &target_resource_ids[0]; + for external_reference_id in asset + .source + .reference_resource_ids + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .collect::>() + { + let source_candidates = resources_by_external_id + .get(external_reference_id) + .map(Vec::as_slice) + .unwrap_or(&[]); + if source_candidates.len() != 1 { + unresolved_reference_resource_ids.insert(external_reference_id.to_string()); + continue; + } + let source_resource_id = &source_candidates[0]; + if !resource_by_id.contains_key(source_resource_id) + || !resource_by_id.contains_key(target_resource_id) + { + continue; + } + let id = stable_edge_id("asset-reference", source_resource_id, target_resource_id); + reference_edge_by_id.insert( + id.clone(), + ProjectResourceReferenceEdge { + id, + kind: "asset-reference".to_string(), + source_resource_id: source_resource_id.clone(), + target_resource_id: target_resource_id.clone(), + cyclic: false, + }, + ); + } + } + let reference_directed_edges = reference_edge_by_id + .values() + .map(|edge| DirectedEdge { + id: edge.id.clone(), + source_id: edge.source_resource_id.clone(), + target_id: edge.target_resource_id.clone(), + }) + .collect::>(); + let reference_cycles = + analyze_directed_cycles(resource_by_id.keys(), &reference_directed_edges); + let reference_edges = reference_edge_by_id + .into_values() + .map(|mut edge| { + edge.cyclic = reference_cycles.cyclic_edge_ids.contains(&edge.id); + edge + }) + .collect::>(); + + let task_dependency_edges = manifest + .tasks + .iter() + .flat_map(|target_task| { + target_task + .dependencies + .iter() + .collect::>() + .into_iter() + .filter(|source_task_id| task_by_id.contains_key(*source_task_id)) + .map(|source_task_id| DirectedEdge { + id: stable_edge_id("task-flow", source_task_id, &target_task.id), + source_id: source_task_id.clone(), + target_id: target_task.id.clone(), + }) + .collect::>() + }) + .collect::>(); + let task_cycles = analyze_directed_cycles(task_by_id.keys(), &task_dependency_edges); + let task_dependency_depths = + dependency_depth_by_node(&task_cycles, &task_dependency_edges, &BTreeMap::new()); + let minimum_resource_dependency_depths = producer_by_resource_id + .iter() + .filter_map(|(resource_id, task_id)| { + task_dependency_depths + .get(task_id) + .copied() + .map(|depth| (resource_id.clone(), depth)) + }) + .collect::>(); + let resource_dependency_depths = dependency_depth_by_node( + &reference_cycles, + &reference_directed_edges, + &minimum_resource_dependency_depths, + ); + let task_flows = task_dependency_edges + .iter() + .filter_map(|edge| { + let source_resource_ids = resources_by_task.get(&edge.source_id)?; + let target_resource_ids = resources_by_task.get(&edge.target_id)?; + (!source_resource_ids.is_empty() && !target_resource_ids.is_empty()).then(|| { + ProjectResourceTaskFlow { + id: edge.id.clone(), + kind: "task-flow".to_string(), + source_task_id: edge.source_id.clone(), + target_task_id: edge.target_id.clone(), + source_resource_ids: source_resource_ids.clone(), + target_resource_ids: target_resource_ids.clone(), + cyclic: task_cycles.cyclic_edge_ids.contains(&edge.id), + } + }) + }) + .collect::>(); + + let mut connection_by_resource_id = resource_by_id + .keys() + .map(|resource_id| (resource_id.clone(), MutableConnectionIndex::default())) + .collect::>(); + for edge in &reference_edges { + if let Some(target) = connection_by_resource_id.get_mut(&edge.target_resource_id) { + target + .upstream_reference_resource_ids + .insert(edge.source_resource_id.clone()); + target.reference_edge_ids.insert(edge.id.clone()); + } + if let Some(source) = connection_by_resource_id.get_mut(&edge.source_resource_id) { + source + .downstream_reference_resource_ids + .insert(edge.target_resource_id.clone()); + source.reference_edge_ids.insert(edge.id.clone()); + } + } + for flow in &task_flows { + for resource_id in flow + .source_resource_ids + .iter() + .chain(flow.target_resource_ids.iter()) + { + if let Some(index) = connection_by_resource_id.get_mut(resource_id) { + index.task_flow_ids.insert(flow.id.clone()); + } + } + } + + ProjectResourceGraphReadModel { + resource_ids: resource_by_id.keys().cloned().collect(), + reference_edges, + task_flows, + connection_index: connection_by_resource_id + .into_iter() + .map(|(resource_id, index)| ProjectResourceConnectionIndex { + resource_id, + upstream_reference_resource_ids: index + .upstream_reference_resource_ids + .into_iter() + .collect(), + downstream_reference_resource_ids: index + .downstream_reference_resource_ids + .into_iter() + .collect(), + reference_edge_ids: index.reference_edge_ids.into_iter().collect(), + task_flow_ids: index.task_flow_ids.into_iter().collect(), + }) + .collect(), + producer_assignments: producer_by_resource_id + .into_iter() + .map(|(resource_id, task_id)| ProjectResourceProducerAssignment { + resource_id, + task_id, + }) + .collect(), + dependency_depths: resource_dependency_depths + .into_iter() + .map( + |(resource_id, dependency_depth)| ProjectResourceDependencyDepth { + resource_id, + dependency_depth, + }, + ) + .collect(), + unresolved_reference_resource_ids: unresolved_reference_resource_ids.into_iter().collect(), + cyclic_resource_ids: reference_cycles.cyclic_node_ids.into_iter().collect(), + cyclic_task_ids: task_cycles.cyclic_node_ids.into_iter().collect(), + producer_mapping_truncated, + } +} + +pub(crate) fn read_project_resource_graph_at( + root: &Path, + expected_project_id: &str, + resources: Vec, +) -> Result { + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != expected_project_id { + return Err("资源依赖图项目身份不匹配".to_string()); + } + let (records, truncated) = + read_agent_db_records_bounded(root, RESOURCE_GRAPH_AGENT_DB_READ_BYTES)?; + Ok(build_project_resource_graph( + &manifest, resources, &records, truncated, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resource( + resource_id: &str, + manifest_asset_id: Option<&str>, + producer_task_id: Option<&str>, + ) -> ProjectResourceGraphNodeInput { + ProjectResourceGraphNodeInput { + resource_id: resource_id.to_string(), + manifest_asset_id: manifest_asset_id.map(ToOwned::to_owned), + producer_task_id: producer_task_id.map(ToOwned::to_owned), + } + } + + fn asset( + id: &str, + external_resource_id: Option<&str>, + references: &[&str], + external_task_id: Option<&str>, + ) -> GameCreationAppAssetManifestEntry { + GameCreationAppAssetManifestEntry { + id: id.to_string(), + kind: "test".to_string(), + media_type: "image/png".to_string(), + local_path: format!("assets/{id}.png"), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: external_resource_id.map(ToOwned::to_owned), + asset_object_id: None, + task_id: external_task_id.map(ToOwned::to_owned), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: references.iter().map(|value| value.to_string()).collect(), + }, + } + } + + fn task(id: &str, dependencies: &[&str]) -> GameCreationAppTaskState { + GameCreationAppTaskState { + id: id.to_string(), + title: id.to_string(), + group: GameCreationAppAgentGroup::Art, + role: "test".to_string(), + dependencies: dependencies.iter().map(|value| value.to_string()).collect(), + artifacts: Vec::new(), + acceptance_criteria: Vec::new(), + status: GameCreationAppTaskStatus::Completed, + } + } + + fn manifest( + tasks: Vec, + assets: Vec, + ) -> GameCreationAppManifest { + let mut manifest = new_game_creation_app_manifest("graph-project", "Graph project"); + manifest.tasks = tasks; + manifest.assets = assets; + manifest + } + + #[test] + fn graph_uses_runtime_agent_identity_instead_of_external_task_id() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("task-1")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("task-2"), + ), + ], + ); + let records = vec![ + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "spec", + "agentId": "art-director" + }), + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "ui", + "agentId": "design-foundation" + }), + ]; + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &records, + false, + ); + + assert_eq!(graph.reference_edges.len(), 1); + assert_eq!(graph.task_flows.len(), 1); + assert_eq!(graph.task_flows[0].source_task_id, "art-director"); + assert_eq!(graph.task_flows[0].target_task_id, "design-foundation"); + assert_eq!( + graph + .producer_assignments + .iter() + .map(|assignment| (assignment.resource_id.as_str(), assignment.task_id.as_str())) + .collect::>(), + BTreeMap::from([ + ("asset:spec", "art-director"), + ("asset:ui", "design-foundation"), + ]), + ); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + assert!(graph + .producer_assignments + .iter() + .all(|assignment| assignment.task_id != "task-1" && assignment.task_id != "task-2")); + } + + #[test] + fn graph_omits_task_flow_without_reliable_runtime_producer_evidence() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("art-director")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("design-foundation"), + ), + ], + ); + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &[], + false, + ); + + assert_eq!(graph.reference_edges.len(), 1); + assert!(graph.task_flows.is_empty()); + assert!(graph.producer_assignments.is_empty()); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + } + + #[test] + fn graph_fails_closed_for_audit_producers_when_agent_db_tail_is_truncated() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("task-1")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("task-2"), + ), + ], + ); + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &[ + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "spec", + "agentId": "art-director" + }), + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "ui", + "agentId": "design-foundation" + }), + ], + true, + ); + + assert!(graph.producer_mapping_truncated); + assert!(graph.producer_assignments.is_empty()); + assert!(graph.task_flows.is_empty()); + assert_eq!(graph.reference_edges.len(), 1); + assert_eq!(graph.reference_edges[0].source_resource_id, "asset:spec"); + assert_eq!(graph.reference_edges[0].target_resource_id, "asset:ui"); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + } + + #[test] + fn graph_aggregates_flows_filters_missing_resources_and_detects_cycles_iteratively() { + let manifest = manifest( + vec![task("task-a", &["task-b"]), task("task-b", &["task-a"])], + vec![ + asset( + "a", + Some("external-a"), + &["external-b", "missing"], + Some("task-1"), + ), + asset("b", Some("external-b"), &["external-a"], Some("task-2")), + ], + ); + let records = vec![ + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "a", "agentId": "task-a"}), + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "b", "agentId": "task-b"}), + ]; + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:a", Some("a"), None), + resource("asset:b", Some("b"), None), + resource("task-a:artifact", None, Some("task-a")), + resource("task-b:artifact", None, Some("task-b")), + ], + &records, + false, + ); + + assert_eq!(graph.reference_edges.len(), 2); + assert!(graph.reference_edges.iter().all(|edge| edge.cyclic)); + assert_eq!(graph.task_flows.len(), 2); + assert!(graph.task_flows.iter().all(|flow| flow.cyclic)); + assert_eq!(graph.unresolved_reference_resource_ids, vec!["missing"]); + assert_eq!(graph.cyclic_resource_ids, vec!["asset:a", "asset:b"]); + assert_eq!(graph.cyclic_task_ids, vec!["task-a", "task-b"]); + assert!( + graph + .task_flows + .iter() + .all(|flow| flow.source_resource_ids.len() == 2 + && flow.target_resource_ids.len() == 2) + ); + } + + #[test] + fn graph_handles_4096_task_chain_without_recursive_traversal_or_cartesian_edges() { + let tasks = (0..4096) + .map(|index| { + let id = format!("task:{index}"); + let dependencies = if index == 0 { + Vec::new() + } else { + vec![format!("task:{}", index - 1)] + }; + GameCreationAppTaskState { + id: id.clone(), + title: id, + group: GameCreationAppAgentGroup::Code, + role: "test".to_string(), + dependencies, + artifacts: Vec::new(), + acceptance_criteria: Vec::new(), + status: GameCreationAppTaskStatus::Completed, + } + }) + .collect::>(); + let resources = (0..4096) + .map(|index| { + resource( + &format!("resource:{index}"), + None, + Some(&format!("task:{index}")), + ) + }) + .collect::>(); + let graph = + build_project_resource_graph(&manifest(tasks, Vec::new()), resources, &[], false); + + assert_eq!(graph.task_flows.len(), 4095); + assert_eq!(graph.connection_index.len(), 4096); + assert!(graph + .connection_index + .iter() + .all(|index| index.task_flow_ids.len() <= 2)); + assert_eq!( + graph + .dependency_depths + .iter() + .find(|depth| depth.resource_id == "resource:4095") + .map(|depth| depth.dependency_depth), + Some(4095), + ); + } + + #[test] + fn dependency_depth_collapses_cycles_before_following_downstream_tasks() { + let graph = build_project_resource_graph( + &manifest( + vec![ + task("source", &[]), + task("cycle-a", &["source", "cycle-b"]), + task("cycle-b", &["cycle-a"]), + task("target", &["cycle-b"]), + ], + Vec::new(), + ), + vec![ + resource("source-resource", None, Some("source")), + resource("cycle-a-resource", None, Some("cycle-a")), + resource("cycle-b-resource", None, Some("cycle-b")), + resource("target-resource", None, Some("target")), + ], + &[], + false, + ); + + let depths = graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(); + assert_eq!(depths["source-resource"], 0); + assert_eq!(depths["cycle-a-resource"], 1); + assert_eq!(depths["cycle-b-resource"], 1); + assert_eq!(depths["target-resource"], 2); + } + + #[test] + fn dependency_depth_uses_reference_sccs_after_task_depth_floors() { + let graph = build_project_resource_graph( + &manifest( + vec![ + task("source-task", &[]), + task("late-task", &["source-task"]), + ], + vec![ + asset("base", Some("external-base"), &[], None), + asset( + "cycle-a", + Some("external-cycle-a"), + &["external-base", "external-cycle-b"], + None, + ), + asset( + "cycle-b", + Some("external-cycle-b"), + &["external-cycle-a"], + None, + ), + asset( + "target", + Some("external-target"), + &["external-cycle-b"], + None, + ), + ], + ), + vec![ + resource("asset:base", Some("base"), None), + resource("asset:cycle-a", Some("cycle-a"), None), + resource("asset:cycle-b", Some("cycle-b"), None), + resource("asset:target", Some("target"), None), + ], + &[ + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "base", "agentId": "source-task"}), + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "cycle-a", "agentId": "late-task"}), + ], + false, + ); + + let depths = graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(); + assert_eq!(depths["asset:base"], 0); + assert_eq!(depths["asset:cycle-a"], 1); + assert_eq!(depths["asset:cycle-b"], 1); + assert_eq!(depths["asset:target"], 2); + assert_eq!( + graph.cyclic_resource_ids, + vec!["asset:cycle-a", "asset:cycle-b"] + ); + assert_eq!(graph.task_flows.len(), 1); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs new file mode 100644 index 000000000..129b01f75 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -0,0 +1,441 @@ +use crate::image_inspect::{ + same_open_file_identity, same_open_file_snapshot, validate_agent_runtime_inspection_ancestors, +}; +use crate::project::{ + normalize_relative_path, open_project_snapshot_regular_file, + reject_sensitive_project_file_read, resolve_local_project_path, +}; +use base64::Engine as _; +use serde::Serialize; +use std::io::Read; +use std::path::Path; + +const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; +const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectTextPreview { + pub(crate) path: String, + pub(crate) media_type: String, + pub(crate) byte_len: u64, + pub(crate) content: String, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectMediaPreview { + pub(crate) path: String, + pub(crate) media_type: String, + pub(crate) byte_len: u64, + pub(crate) data_url: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProjectMediaPreviewKind { + Art, + Audio, +} + +pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml") + ) && (media_type.is_empty() + || media_type.starts_with("text/") + || media_type.contains("json") + || media_type.contains("yaml") + || matches!( + media_type.as_str(), + "项目文档" | "application/toml" | "application/mdx" + )) +} + +pub(crate) fn is_supported_project_art_media_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("gif" | "svg" | "avif" | "bmp" | "mp4" | "webm" | "mov") + ) || media_type.starts_with("video/") + || media_type == "image/svg+xml" +} + +pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus") + ) || media_type.starts_with("audio/") +} + +pub(crate) fn load_local_project_text_preview( + root: &Path, + relative_path: &str, +) -> Result { + let normalized = normalize_relative_path(relative_path.trim())?; + reject_sensitive_project_file_read(&normalized)?; + let media_type = project_text_media_type(&normalized) + .ok_or_else(|| "文档预览只支持 Markdown、文本、JSON、YAML 和 TOML".to_string())?; + let bytes = read_stable_project_resource( + root, + &normalized, + PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES, + "项目文档", + )?; + let content = + String::from_utf8(bytes).map_err(|_| "文档预览只支持 UTF-8 编码的文本文件".to_string())?; + Ok(LocalProjectTextPreview { + path: normalized, + media_type: media_type.to_string(), + byte_len: content.len() as u64, + content, + }) +} + +pub(crate) fn load_local_project_media_preview( + root: &Path, + relative_path: &str, + kind: ProjectMediaPreviewKind, +) -> Result { + let normalized = normalize_relative_path(relative_path.trim())?; + reject_sensitive_project_file_read(&normalized)?; + let bytes = read_stable_project_resource( + root, + &normalized, + PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, + "项目媒体资源", + )?; + if bytes.is_empty() { + return Err("媒体文件为空,无法预览".to_string()); + } + let media_type = detect_project_media_type(&normalized, &bytes, kind)?; + Ok(LocalProjectMediaPreview { + path: normalized, + media_type: media_type.to_string(), + byte_len: bytes.len() as u64, + data_url: format!( + "data:{media_type};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ), + }) +} + +fn read_stable_project_resource( + root: &Path, + normalized: &str, + max_bytes: u64, + label: &str, +) -> Result, String> { + let absolute = resolve_local_project_path(root, normalized)?; + validate_agent_runtime_inspection_ancestors(root, &absolute)?; + let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?; + if initial_metadata.len() > max_bytes { + return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024)); + } + let mut bytes = Vec::with_capacity(initial_metadata.len() as usize); + file.by_ref() + .take(max_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?; + if bytes.len() as u64 > max_bytes { + return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024)); + } + let final_metadata = file + .metadata() + .map_err(|error| format!("复核{label}失败:{normalized}: {error}"))?; + if initial_metadata.len() != bytes.len() as u64 + || final_metadata.len() != bytes.len() as u64 + || !same_open_file_snapshot(&initial_metadata, &final_metadata) + { + return Err(format!("{label}读取期间发生漂移:{normalized}")); + } + let (reopened, reopened_metadata) = open_project_snapshot_regular_file(&absolute, label)?; + if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? { + return Err(format!("{label}路径读取期间发生替换:{normalized}")); + } + Ok(bytes) +} + +fn project_text_media_type(path: &str) -> Option<&'static str> { + match path_extension(path).as_deref()? { + "md" | "markdown" | "mdx" => Some("text/markdown"), + "txt" => Some("text/plain"), + "json" => Some("application/json"), + "yaml" | "yml" => Some("application/yaml"), + "toml" => Some("application/toml"), + _ => None, + } +} + +fn detect_project_media_type( + path: &str, + bytes: &[u8], + kind: ProjectMediaPreviewKind, +) -> Result<&'static str, String> { + if kind == ProjectMediaPreviewKind::Art && path_extension(path).as_deref() == Some("svg") { + validate_safe_svg(bytes)?; + return Ok("image/svg+xml"); + } + if kind == ProjectMediaPreviewKind::Art { + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Ok("image/gif"); + } + if bytes.starts_with(b"BM") { + return Ok("image/bmp"); + } + if is_avif(bytes) { + return Ok("image/avif"); + } + if is_iso_base_media(bytes) { + return Ok(if path_extension(path).as_deref() == Some("mov") { + "video/quicktime" + } else { + "video/mp4" + }); + } + if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) { + return Ok("video/webm"); + } + return Err("美术媒体预览只支持 GIF、安全 SVG、AVIF、BMP、MP4、WebM 或 MOV".to_string()); + } + + if looks_like_id3(bytes) || looks_like_mp3_frame(bytes) { + Ok("audio/mpeg") + } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE" { + Ok("audio/wav") + } else if bytes.starts_with(b"OggS") { + Ok("audio/ogg") + } else if bytes.starts_with(b"fLaC") { + Ok("audio/flac") + } else if is_avif(bytes) { + Err("音乐音效文件签名与登记类型不一致".to_string()) + } else if is_iso_base_media(bytes) { + Ok("audio/mp4") + } else if looks_like_aac_adts(bytes) { + Ok("audio/aac") + } else { + Err("音乐音效预览只支持 MP3、WAV、OGG、M4A、AAC、FLAC 或 Opus".to_string()) + } +} + +fn validate_safe_svg(bytes: &[u8]) -> Result<(), String> { + let text = std::str::from_utf8(bytes).map_err(|_| "SVG 必须使用 UTF-8 编码".to_string())?; + let lower = text.to_ascii_lowercase(); + if !lower.contains(" bool { + let mut remaining = text; + while let Some(index) = remaining.find("href") { + let after_name = &remaining[index + 4..]; + let Some(after_equals) = after_name.trim_start().strip_prefix('=') else { + remaining = after_name; + continue; + }; + let value = after_equals.trim_start(); + let value = value + .strip_prefix('\'') + .or_else(|| value.strip_prefix('"')) + .unwrap_or(value) + .trim_start(); + if !value.starts_with('#') { + return true; + } + remaining = after_name; + } + false +} + +fn contains_unsafe_svg_url(text: &str) -> bool { + let mut remaining = text; + while let Some(index) = remaining.find("url(") { + let value = remaining[index + 4..].trim_start(); + let value = value + .strip_prefix('\'') + .or_else(|| value.strip_prefix('"')) + .unwrap_or(value) + .trim_start(); + if !value.starts_with('#') { + return true; + } + remaining = &remaining[index + 4..]; + } + false +} + +fn contains_svg_event_handler(text: &str) -> bool { + let bytes = text.as_bytes(); + let mut index = 0usize; + while index + 3 < bytes.len() { + if bytes[index].is_ascii_whitespace() && bytes[index + 1..].starts_with(b"on") { + let mut cursor = index + 3; + while cursor < bytes.len() && bytes[cursor].is_ascii_alphabetic() { + cursor += 1; + } + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if cursor < bytes.len() && bytes[cursor] == b'=' { + return true; + } + } + index += 1; + } + false +} + +fn is_iso_base_media(bytes: &[u8]) -> bool { + bytes.len() >= 12 && &bytes[4..8] == b"ftyp" +} + +fn is_avif(bytes: &[u8]) -> bool { + is_iso_base_media(bytes) + && (&bytes[8..12] == b"avif" + || &bytes[8..12] == b"avis" + || bytes[8..].windows(4).any(|brand| brand == b"avif")) +} + +fn looks_like_mp3_frame(bytes: &[u8]) -> bool { + bytes.len() >= 4 + && bytes[0] == 0xff + && bytes[1] & 0xe0 == 0xe0 + && bytes[1] & 0x06 != 0 + && bytes[2] & 0xf0 != 0xf0 + && bytes[2] & 0x0c != 0x0c +} + +fn looks_like_id3(bytes: &[u8]) -> bool { + if bytes.len() < 10 || !bytes.starts_with(b"ID3") || bytes[3] == 0xff || bytes[4] == 0xff { + return false; + } + let size_bytes = &bytes[6..10]; + if size_bytes.iter().any(|byte| byte & 0x80 != 0) { + return false; + } + let tag_size = size_bytes + .iter() + .fold(0usize, |size, byte| (size << 7) | usize::from(*byte)); + 10usize + .checked_add(tag_size) + .is_some_and(|required| required <= bytes.len()) +} + +fn looks_like_aac_adts(bytes: &[u8]) -> bool { + bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xf6 == 0xf0 +} + +fn path_extension(path: &str) -> Option { + Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn text_preview_requires_utf8_and_a_supported_extension() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("docs")).expect("docs dir"); + fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown"); + fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text"); + fs::write(root.path().join("docs/page.html"), "

unsafe

").expect("html"); + + let preview = + load_local_project_text_preview(root.path(), "docs/design.md").expect("load markdown"); + assert_eq!(preview.media_type, "text/markdown"); + assert!(preview.content.contains("正文")); + assert!(load_local_project_text_preview(root.path(), "docs/legacy.txt").is_err()); + assert!(load_local_project_text_preview(root.path(), "docs/page.html").is_err()); + } + + #[test] + fn media_preview_accepts_safe_svg_and_rejects_active_svg() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + fs::write( + root.path().join("assets/icon.svg"), + "", + ) + .expect("svg"); + fs::write( + root.path().join("assets/active.svg"), + "", + ) + .expect("active svg"); + fs::write( + root.path().join("assets/external.svg"), + "", + ) + .expect("external svg"); + + let preview = load_local_project_media_preview( + root.path(), + "assets/icon.svg", + ProjectMediaPreviewKind::Art, + ) + .expect("safe svg"); + assert_eq!(preview.media_type, "image/svg+xml"); + assert!(preview.data_url.starts_with("data:image/svg+xml;base64,")); + assert!(load_local_project_media_preview( + root.path(), + "assets/active.svg", + ProjectMediaPreviewKind::Art, + ) + .is_err()); + assert!(load_local_project_media_preview( + root.path(), + "assets/external.svg", + ProjectMediaPreviewKind::Art, + ) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn resource_preview_rejects_symlink_and_hardlink_files() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().expect("temp root"); + let outside = tempfile::tempdir().expect("outside"); + fs::create_dir_all(root.path().join("docs")).expect("docs dir"); + let source = outside.path().join("source.md"); + fs::write(&source, "secret").expect("source"); + symlink(&source, root.path().join("docs/link.md")).expect("symlink"); + fs::hard_link(&source, root.path().join("docs/hard.md")).expect("hardlink"); + + assert!(load_local_project_text_preview(root.path(), "docs/link.md").is_err()); + assert!(load_local_project_text_preview(root.path(), "docs/hard.md").is_err()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 155d49037..e4d35d654 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -1,5 +1,8 @@ use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*}; -use crate::{AgentRuntimeContextCompactionResult, GameCreatorMcpCatalog}; +use crate::{ + AgentRuntimeContextCompactionResult, GameCreatorManifestInvalidationEventSink, + GameCreatorMcpCatalog, +}; use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::ffi::OsString; @@ -982,7 +985,9 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> { shutdown_external_agent_runner_at(&config_dir) } -pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { +pub(crate) fn attach_external_agent_runner_gui_owner( + event_sink: &GameCreatorManifestInvalidationEventSink, +) -> Result<(), String> { EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .store(true, std::sync::atomic::Ordering::Release); let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); @@ -991,21 +996,33 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { register_external_agent_runner_gui_owner_attachment( external_agent_runner_gui_owner_attachment_state(), &config_dir, - ExternalAgentRunnerRequestParams::default(), + ExternalAgentRunnerRequestParams { + event_sink_port: Some(event_sink.port), + event_sink_token: Some(event_sink.token.clone()), + ..ExternalAgentRunnerRequestParams::default() + }, ); ensure_external_agent_runner(&config_dir).map(|_| ()) } +pub(super) fn validate_external_agent_runner_gui_owner_attachment_result( + result: &Value, +) -> Result<(), String> { + if result.get("attached").and_then(Value::as_bool) == Some(true) + && result.get("eventSinkAttached").and_then(Value::as_bool) == Some(true) + { + Ok(()) + } else { + Err("Agent Runner attach_gui_owner 响应未确认 owner 与事件接收端".to_string()) + } +} + fn attach_external_agent_runner_gui_owner_at( endpoint: &ExternalAgentRunnerEndpoint, params: ExternalAgentRunnerRequestParams, ) -> Result<(), String> { let result = send_external_agent_runner_request(endpoint, "runner.attach_gui_owner", params)?; - if result.get("attached").and_then(Value::as_bool) == Some(true) { - Ok(()) - } else { - Err("Agent Runner attach_gui_owner 响应未确认 owner".to_string()) - } + validate_external_agent_runner_gui_owner_attachment_result(&result) } fn attach_registered_external_agent_runner_gui_owner_if_needed( @@ -1276,6 +1293,8 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( run_id: run_id.map(str::to_string), action_id: action_id.map(str::to_string), steer_id: steer_id.map(str::to_string), + event_sink_port: None, + event_sink_token: None, }; match stable_identity { Some(stable_identity) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 5bc8667ef..aa17ffefe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -1,4 +1,5 @@ use super::{endpoint::*, project_owner::*, protocol::*, state::*}; +use crate::configure_game_creator_manifest_invalidation_event_sink; use serde::Deserialize; use serde_json::json; use sha2::{Digest as _, Sha256}; @@ -587,10 +588,27 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( "runner.attach_gui_owner" => { match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { Ok(true) => { + let event_sink = request + .params + .event_sink_port + .zip(request.params.event_sink_token.as_deref()) + .ok_or_else(|| { + "Agent Runner GUI owner 缺少 manifest 事件接收端".to_string() + }) + .and_then(|(port, token)| { + configure_game_creator_manifest_invalidation_event_sink(port, token) + }); + if let Err(error) = event_sink { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "event-sink-invalid", + redact_runner_secret(&error, &token), + ); + } state.gui_owner_attached.store(true, Ordering::Release); ExternalAgentRunnerResponse::success( &request.request_id, - json!({ "attached": true }), + json!({ "attached": true, "eventSinkAttached": true }), ) } Ok(false) => ExternalAgentRunnerResponse::failure( diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 3eda73886..451309200 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; -pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 4; +pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 5; pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; @@ -264,6 +264,10 @@ pub(super) struct ExternalAgentRunnerRequestParams { pub(super) action_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) steer_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) event_sink_port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) event_sink_token: Option, } #[derive(Deserialize, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index e4fc14bf5..4e28d62ac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -85,7 +85,7 @@ fn context_compaction_client_uses_long_response_timeout_without_widening_other_m assert!( external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT ); - assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 4); + assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 5); } fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint { @@ -556,8 +556,11 @@ fn runner_endpoint_rejects_hard_links() { fn gui_owner_registration_replays_once_for_each_runner_boot() { let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); let config_dir = PathBuf::from("registered-gui-appdata"); + let event_sink_port = 31_317; + let event_sink_token = "a".repeat(64); let params = ExternalAgentRunnerRequestParams { - action_id: Some("registered-owner-params".to_string()), + event_sink_port: Some(event_sink_port), + event_sink_token: Some(event_sink_token.clone()), ..ExternalAgentRunnerRequestParams::default() }; register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params); @@ -575,7 +578,12 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { |endpoint, params| { calls.borrow_mut().push(( endpoint.boot_id.clone(), - params.action_id.expect("registered params are retained"), + params + .event_sink_port + .expect("registered sink port is retained"), + params + .event_sink_token + .expect("registered sink token is retained"), )); Ok(()) }, @@ -601,7 +609,12 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { |endpoint, params| { calls.borrow_mut().push(( endpoint.boot_id.clone(), - params.action_id.expect("registered params are replayed"), + params + .event_sink_port + .expect("registered sink port is replayed"), + params + .event_sink_token + .expect("registered sink token is replayed"), )); Ok(()) }, @@ -613,11 +626,13 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { vec![ ( "gui-owner-boot-a".to_string(), - "registered-owner-params".to_string() + event_sink_port, + event_sink_token.clone(), ), ( "gui-owner-boot-b".to_string(), - "registered-owner-params".to_string() + event_sink_port, + event_sink_token, ), ] ); @@ -670,15 +685,117 @@ fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() { assert_eq!(attempts.get(), 2); } +#[test] +fn gui_owner_registration_missing_event_sink_confirmation_retries_same_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("missing-sink-confirmation-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_322), + event_sink_token: Some("c".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + let endpoint = test_endpoint( + "missing-sink-confirmation-runner-token", + "missing-sink-confirmation-boot", + 31_322, + ); + let attempts = std::cell::Cell::new(0_u32); + + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true + })) + }, + ) + .expect_err("missing eventSinkAttached must fail"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true, + "eventSinkAttached": true + })) + }, + ) + .expect("same boot retries after missing event sink confirmation"); + assert_eq!(attempts.get(), 2); +} + +#[test] +fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("false-sink-confirmation-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_323), + event_sink_token: Some("d".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + let endpoint = test_endpoint( + "false-sink-confirmation-runner-token", + "false-sink-confirmation-boot", + 31_323, + ); + let attempts = std::cell::Cell::new(0_u32); + + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true, + "eventSinkAttached": false + })) + }, + ) + .expect_err("false eventSinkAttached must fail"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true, + "eventSinkAttached": true + })) + }, + ) + .expect("same boot retries after false event sink confirmation"); + assert_eq!(attempts.get(), 2); +} + #[test] fn gui_owner_registration_does_not_cross_config_dirs() { let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); let registered_config_dir = PathBuf::from("registered-gui-appdata"); let other_config_dir = PathBuf::from("other-gui-appdata"); + let event_sink_token = "e".repeat(64); register_external_agent_runner_gui_owner_attachment( &state, ®istered_config_dir, - ExternalAgentRunnerRequestParams::default(), + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_324), + event_sink_token: Some(event_sink_token.clone()), + ..ExternalAgentRunnerRequestParams::default() + }, ); let endpoint = test_endpoint( "gui-owner-config-token-gui-owner-config-token", @@ -698,8 +815,13 @@ fn gui_owner_registration_does_not_cross_config_dirs() { &state, ®istered_config_dir, &endpoint, - |_, _| { + |_, params| { calls.set(calls.get() + 1); + assert_eq!(params.event_sink_port, Some(31_324)); + assert_eq!( + params.event_sink_token.as_deref(), + Some(event_sink_token.as_str()) + ); Ok(()) }, ) @@ -732,7 +854,9 @@ fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() { } #[test] -fn attached_gui_owner_loss_forces_runner_shutdown() { +fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_up() { + let sink_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + assert_eq!(sink_guard.configured_sink(), None); let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); let token = "gui-owner-monitor-token-gui-owner-monitor-token"; @@ -748,12 +872,23 @@ fn attached_gui_owner_loss_forces_runner_shutdown() { request_id: "gui-owner-attach-1".to_string(), token: token.to_string(), method: "runner.attach_gui_owner".to_string(), - params: ExternalAgentRunnerRequestParams::default(), + params: ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_318), + event_sink_token: Some("b".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, }, &state, ); assert!(attached.ok); assert!(state.gui_owner_attached.load(Ordering::Acquire)); + assert_eq!( + sink_guard.configured_sink(), + Some(crate::GameCreatorManifestInvalidationEventSink { + port: 31_318, + token: "b".repeat(64), + }) + ); assert!( !external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present") ); @@ -764,6 +899,9 @@ fn attached_gui_owner_loss_forces_runner_shutdown() { assert!(state.draining.load(Ordering::Acquire)); assert!(state.force_shutdown_requested.load(Ordering::Acquire)); assert!(state.shutdown_requested.load(Ordering::Acquire)); + drop(sink_guard); + let cleanup_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + assert_eq!(cleanup_guard.configured_sink(), None); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs index a255f8748..8365f3873 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs @@ -1606,5 +1606,12 @@ async fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reser .expect("read barrier after rejecting reserved delivery") .is_clear()); + let released = wait_for_agent_runtime_lane_release_async( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .await; + assert_eq!(released.state.run_id, parent_run_id); + fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 136e1097f..0fac08451 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -3,15 +3,159 @@ use base64::Engine as _; use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::collections::{BTreeMap, BTreeSet}; -use std::io::{Read, Write}; +use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Barrier, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use zip::write::SimpleFileOptions; static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0); static TEST_MOCK_PORT_COUNTER: AtomicU64 = AtomicU64::new(20_000); static TEST_CONFIG_LOCK: StdMutex<()> = StdMutex::new(()); +const MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT: Duration = Duration::from_millis(500); +const MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT: Duration = Duration::from_millis(500); +const MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES: usize = 64 * 1024; + +fn read_manifest_invalidation_relay_payload_with_deadline( + listener: &TcpListener, +) -> io::Result> { + listener.set_nonblocking(true)?; + let accept_deadline = Instant::now() + MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT; + let (mut stream, _) = loop { + match listener.accept() { + Ok(accepted) => break accepted, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if Instant::now() >= accept_deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "manifest invalidation relay accept timed out", + )); + } + std::thread::yield_now(); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + }; + + stream.set_nonblocking(true)?; + let payload_deadline = Instant::now() + MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT; + let mut payload = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + match stream.read(&mut buffer) { + Ok(0) => return Ok(payload), + Ok(read) => { + payload.extend_from_slice(&buffer[..read]); + if payload.len() > MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "manifest invalidation relay payload exceeded test limit", + )); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if Instant::now() >= payload_deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "manifest invalidation relay payload timed out", + )); + } + std::thread::yield_now(); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } +} + +#[test] +fn manifest_invalidation_sink_isolation_relays_non_supervisor_runtime_update() { + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + let root = unique_project_path(); + init_local_game_project_at(&root, "runtime-event-contract", "Runtime 事件合同测试") + .expect("init runtime event contract project"); + let runtime = read_game_creator_agent_runtime_at(&root, "art-asset-plan") + .expect("read non-Supervisor runtime"); + let event = game_creator_agent_runtime_update_event(&root, runtime); + let serialized = serde_json::to_value(event).expect("serialize runtime update event"); + + assert_eq!(serialized["agentId"], "art-asset-plan"); + assert_eq!(serialized["manifestInvalidated"], true); + + let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind manifest invalidation relay fixture"); + let relay_port = relay_listener + .local_addr() + .expect("read manifest invalidation relay fixture address") + .port(); + let relay_token = "a".repeat(64); + sink_guard + .configure(relay_port, &relay_token) + .expect("configure manifest invalidation relay fixture"); + emit_game_creator_agent_runtime_update(&root, "art-asset-plan"); + let relay_payload = read_manifest_invalidation_relay_payload_with_deadline(&relay_listener) + .expect("receive manifest invalidation relay within deadline"); + let relay: GameCreatorManifestInvalidationRelayEnvelope = + serde_json::from_slice(&relay_payload).expect("parse manifest invalidation relay"); + assert_eq!(relay.token, relay_token); + assert_eq!(relay.event.project_path, root.to_string_lossy()); + assert_eq!(relay.event.agent_id, "art-asset-plan"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn manifest_invalidation_sink_isolation_bounds_timeouts_and_cleans_up_with_raii() { + let cleanup_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind manifest invalidation cleanup fixture"); + let cleanup_port = cleanup_listener + .local_addr() + .expect("read manifest invalidation cleanup fixture address") + .port(); + let cleanup_token = "b".repeat(64); + let unwind = std::panic::catch_unwind(|| { + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + sink_guard + .configure(cleanup_port, &cleanup_token) + .expect("configure manifest invalidation cleanup fixture"); + assert_eq!( + sink_guard.configured_sink(), + Some(GameCreatorManifestInvalidationEventSink { + port: cleanup_port, + token: cleanup_token.clone(), + }) + ); + panic!("exercise manifest invalidation sink guard unwind cleanup"); + }); + assert!(unwind.is_err()); + + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + assert_eq!(sink_guard.configured_sink(), None); + + let empty_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind empty manifest invalidation relay fixture"); + let accept_started = Instant::now(); + let accept_error = read_manifest_invalidation_relay_payload_with_deadline(&empty_listener) + .expect_err("missing relay must time out"); + assert_eq!(accept_error.kind(), io::ErrorKind::TimedOut); + assert!(accept_started.elapsed() < Duration::from_secs(2)); + + let stalled_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind stalled manifest invalidation relay fixture"); + let stalled_stream = TcpStream::connect( + stalled_listener + .local_addr() + .expect("read stalled manifest invalidation relay fixture address"), + ) + .expect("connect stalled manifest invalidation relay fixture"); + let payload_started = Instant::now(); + let payload_error = read_manifest_invalidation_relay_payload_with_deadline(&stalled_listener) + .expect_err("incomplete relay payload must time out"); + assert_eq!(payload_error.kind(), io::ErrorKind::TimedOut); + assert!(payload_started.elapsed() < Duration::from_secs(2)); + drop(stalled_stream); +} #[test] fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() { @@ -143,6 +287,16 @@ fn unique_project_path() -> PathBuf { )) } +pub(crate) fn canonical_test_tempdir(prefix: &str) -> tempfile::TempDir { + let temp_root = std::env::temp_dir() + .canonicalize() + .expect("canonicalize test temp root"); + tempfile::Builder::new() + .prefix(prefix) + .tempdir_in(temp_root) + .expect("create test temp directory under canonical root") +} + fn agent_goal_sidecar_path_for_test(root: &Path, agent_id: &str, session_id: &str) -> PathBuf { let path_key = |value: &str| { format!("{:x}", Sha256::digest(value.as_bytes())) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 4ff135ddf..141878dab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -6090,3 +6090,107 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { fs::remove_dir_all(root).ok(); } + +#[test] +fn local_project_resource_previews_require_registered_safe_resources() { + let root = unique_project_path(); + init_local_game_project_at(&root, "resource-preview-policy", "资源预览策略项目") + .expect("project init"); + fs::create_dir_all(root.join("assets")).expect("asset dir"); + fs::create_dir_all(root.join("game")).expect("game dir"); + fs::write(root.join("game/design.md"), "# 玩法设计\n\n安全正文").expect("project document"); + fs::write( + root.join("assets/icon.svg"), + "", + ) + .expect("svg resource"); + fs::write( + root.join("assets/bgm.mp3"), + [b'I', b'D', b'3', 4, 0, 0, 0, 0, 0, 0], + ) + .expect("audio resource"); + fs::write(root.join("game/unregistered.md"), "不应读取").expect("unregistered document"); + + let source = || GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }; + register_local_asset_at( + &root, + "game/design.md", + "design-document", + "text/markdown", + "generated", + source(), + ) + .expect("register document"); + register_local_asset_at( + &root, + "assets/icon.svg", + "icon", + "image/svg+xml", + "generated", + source(), + ) + .expect("register svg"); + register_local_asset_at( + &root, + "assets/bgm.mp3", + "bgm", + "audio/mpeg", + "generated", + source(), + ) + .expect("register audio"); + + let document = read_local_project_text_preview( + root.to_string_lossy().into_owned(), + "game/design.md".to_string(), + ) + .expect("read registered document"); + assert_eq!(document.media_type, "text/markdown"); + assert!(document.content.contains("安全正文")); + + let svg = read_local_project_media_preview( + root.to_string_lossy().into_owned(), + "assets/icon.svg".to_string(), + "art".to_string(), + ) + .expect("read registered svg"); + assert_eq!(svg.media_type, "image/svg+xml"); + let audio = read_local_project_media_preview( + root.to_string_lossy().into_owned(), + "assets/bgm.mp3".to_string(), + "audio".to_string(), + ) + .expect("read registered audio"); + assert_eq!(audio.media_type, "audio/mpeg"); + + let unregistered_error = read_local_project_text_preview( + root.to_string_lossy().into_owned(), + "game/unregistered.md".to_string(), + ) + .expect_err("unregistered document rejected"); + assert!(unregistered_error.contains("已登记的文档资源")); + assert!(read_local_project_text_preview( + root.to_string_lossy().into_owned(), + "../outside.md".to_string(), + ) + .is_err()); + assert!(read_local_project_media_preview( + root.to_string_lossy().into_owned(), + "assets/bgm.mp3".to_string(), + "art".to_string(), + ) + .is_err()); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 040b2c83b..077839dc5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -3008,11 +3008,23 @@ async fn background_task_recovers_when_assistant_message_cannot_persist() { .messages .iter() .all(|message| message.role != "assistant")); - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - let audit_records = agent_db - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .collect::>(); + let mut audit_records = Vec::new(); + for _ in 0..100 { + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + audit_records = agent_db + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect::>(); + if audit_records.iter().any(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.background_task.finalization_pending") + && record.get("runId").and_then(Value::as_str) + == Some("assistant-conversation-write-failure-run") + }) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } assert!(audit_records.iter().any(|record| { record.get("recordType").and_then(Value::as_str) == Some("agent.runtime.background_task.finalization_pending") diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 57ccaf972..8089cbc32 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -53,6 +53,7 @@ import type { GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, GameCreatorLlmConfigStatus, + GameCreatorManifestInvalidatedEvent, GameCreatorRoleAgentChatStreamEvent, GenerateLocalGameDraftResult, ImportCanvasExportResult, @@ -566,6 +567,10 @@ type AppProps = { supervisorChatOnly?: boolean; gameChatOnly?: boolean; initialSupervisorMessage?: string; + onManifestChange?: ( + projectPath: string, + manifest: GameCreationAppManifest, + ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], @@ -580,6 +585,7 @@ export function App({ supervisorChatOnly = false, gameChatOnly = false, initialSupervisorMessage = '', + onManifestChange, onPreviewChange, onAgentRuntimeSummariesChange, onAgentResultsChange, @@ -607,6 +613,16 @@ export function App({ ); const localProjectPathRef = useRef(null); localProjectPathRef.current = localProject?.projectPath ?? null; + const manifestRefreshMountedRef = useRef(true); + const manifestRefreshStatesRef = useRef( + new Map< + string, + { + pending: boolean; + inFlight: Promise | null; + } + >(), + ); const [manifest, setManifest] = useState( initialProjectManifest ?? seedManifest, ); @@ -813,6 +829,77 @@ export function App({ const projectSupervisorResponseStreamRef = useRef(null); projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream; + + const refreshManifest = useCallback( + ( + nextProjectPath = localProjectPathRef.current ?? '', + ): Promise => { + const invoke = resolveTauriInvoke(); + if (!invoke || !nextProjectPath) { + return Promise.resolve(); + } + + const refreshStates = manifestRefreshStatesRef.current; + let refreshState = refreshStates.get(nextProjectPath); + if (!refreshState) { + refreshState = { pending: false, inFlight: null }; + refreshStates.set(nextProjectPath, refreshState); + } + refreshState.pending = true; + if (refreshState.inFlight) { + return refreshState.inFlight; + } + + const activeRefreshState = refreshState; + const refreshPromise = (async () => { + try { + while (activeRefreshState.pending) { + activeRefreshState.pending = false; + const projectScopeVersion = projectScopeVersionRef.current; + try { + const nextManifest = await invoke( + 'get_local_game_manifest', + { projectPath: nextProjectPath }, + ); + if ( + manifestRefreshMountedRef.current && + localProjectPathRef.current === nextProjectPath && + projectScopeVersionRef.current === projectScopeVersion + ) { + setManifest(nextManifest); + } + } catch { + // Dev-only convenience; command errors are surfaced by the action that triggered them. + } + if ( + !manifestRefreshMountedRef.current || + localProjectPathRef.current !== nextProjectPath || + projectScopeVersionRef.current !== projectScopeVersion + ) { + activeRefreshState.pending = false; + } + } + } finally { + activeRefreshState.inFlight = null; + if (!activeRefreshState.pending) { + refreshStates.delete(nextProjectPath); + } + } + })(); + activeRefreshState.inFlight = refreshPromise; + return refreshPromise; + }, + [], + ); + + useEffect(() => { + const refreshStates = manifestRefreshStatesRef.current; + manifestRefreshMountedRef.current = true; + return () => { + manifestRefreshMountedRef.current = false; + refreshStates.clear(); + }; + }, []); const projectSupervisorRuntimeSyncingRef = useRef(new Set()); const projectSupervisorRefreshConversationRef = useRef< | (( @@ -1301,6 +1388,9 @@ export function App({ if (payload.projectPath !== localProjectPathRef.current) { return; } + if (payload.manifestInvalidated) { + void refreshManifest(payload.projectPath); + } const nextRuntime = agentRuntimeStateFromResult(payload.runtime); if (gameChatOnly && payload.agentId !== PROJECT_SUPERVISOR_AGENT_ID) { appendGameChatFinalReplyMessages(payload.projectPath, [ @@ -1390,10 +1480,43 @@ export function App({ }, [ appendGameChatFinalReplyMessages, gameChatOnly, + refreshManifest, updateProjectSupervisorResponseStream, updateProjectSupervisorRuntime, ]); + useEffect(() => { + const listen = window.__TAURI__?.event?.listen; + if (!listen) { + return; + } + let cleanup: (() => void) | null = null; + let disposed = false; + void listen( + 'game-creator-manifest-invalidated', + (event) => { + if (event.payload.projectPath !== localProjectPathRef.current) { + return; + } + void refreshManifest(event.payload.projectPath); + }, + ) + .then((unlisten) => { + if (disposed) { + unlisten(); + return; + } + cleanup = unlisten; + }) + .catch(() => { + // In-process Runtime events continue to carry the same invalidation signal. + }); + return () => { + disposed = true; + cleanup?.(); + }; + }, [refreshManifest]); + useEffect(() => { const invoke = resolveTauriInvoke(); const nextProjectPath = localProject?.projectPath ?? null; @@ -9984,25 +10107,6 @@ export function App({ } } - async function refreshManifest( - nextProjectPath = resolveChatProjectPath(localProject) ?? '', - ) { - const invoke = resolveTauriInvoke(); - if (!invoke || !nextProjectPath) { - return; - } - - try { - const nextManifest = await invoke( - 'get_local_game_manifest', - { projectPath: nextProjectPath }, - ); - setManifest(nextManifest); - } catch { - // Dev-only convenience; command errors are surfaced by the action that triggered them. - } - } - async function loadAgentRunTraceFile( relativePath: string, nextProjectPath = resolveChatProjectPath(localProject) ?? '', @@ -10442,6 +10546,18 @@ export function App({ const professionalResultCandidateKey = professionalResultCandidates .map((candidate) => `${candidate.agentId}:${candidate.runtimeUpdatedAt}`) .join('|'); + useEffect(() => { + const nextProjectPath = localProject?.projectPath; + if (!projectSupervisorOnly || !nextProjectPath || !onManifestChange) { + return; + } + onManifestChange(nextProjectPath, manifest); + }, [ + localProject?.projectPath, + manifest, + onManifestChange, + projectSupervisorOnly, + ]); useEffect(() => { const invoke = resolveTauriInvoke(); const nextProjectPath = localProject?.projectPath ?? null; diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 8cec58340..3c44f0284 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -426,9 +426,15 @@ export interface GameCreatorAgentRuntimeUpdateEvent { runId: string; status: string; phase: string; + manifestInvalidated: boolean; runtime: AgentRuntimeResult; } +export interface GameCreatorManifestInvalidatedEvent { + projectPath: string; + agentId: string; +} + export const gameCreatorLlmReasoningEfforts = [ 'default', 'low', diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index e9a84d703..7cd31249c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState } from 'react'; +import { Fragment, useCallback, useState } from 'react'; import { launcherNotifications } from '../../app/constants'; import { closeDialogOnEscape } from '../../app/dialogs'; @@ -45,6 +45,7 @@ export function WorkspaceLauncherShell({ projectPath, setProjectPath, currentProjectContext, + setCurrentProjectContext, activeProjectPreview, setActiveProjectPreview, activeProjectAgentRuntimeSummaries, @@ -55,6 +56,24 @@ export function WorkspaceLauncherShell({ createHomeDraft, openProject, } = homeProject; + const syncActiveProjectManifest = useCallback( + ( + sourceProjectPath: string, + manifest: NonNullable['manifest'], + ) => { + setCurrentProjectContext((current) => { + if ( + !current || + current.projectPath !== sourceProjectPath || + current.manifest === manifest + ) { + return current; + } + return { ...current, manifest }; + }); + }, + [setCurrentProjectContext], + ); function showLauncherNotice(title: string) { setLauncherNotice({ @@ -163,6 +182,7 @@ export function WorkspaceLauncherShell({ initialProjectPath={currentProjectContext.projectPath} initialProjectManifest={currentProjectContext.manifest} projectSupervisorOnly + onManifestChange={syncActiveProjectManifest} onPreviewChange={setActiveProjectPreview} onAgentRuntimeSummariesChange={ setActiveProjectAgentRuntimeSummaries diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 624d2bec5..c615e387d 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -34,6 +34,10 @@ export type ProjectSupervisorComponentProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; projectSupervisorOnly?: boolean; + onManifestChange?: ( + projectPath: string, + manifest: GameCreationAppManifest, + ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 9d15570cb..055d266f0 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -430,6 +430,7 @@ export function useHomeProjectCreation({ projectPath, setProjectPath, currentProjectContext, + setCurrentProjectContext, activeProjectPreview, setActiveProjectPreview, activeProjectAgentRuntimeSummaries, diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 0cfe726c2..1573fb5d1 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -4098,8 +4098,6 @@ iframe.preview-frame { .game-resource-canvas { position: relative; - display: grid; - align-content: start; flex: 1; min-height: 0; padding: 12px; @@ -4109,7 +4107,84 @@ iframe.preview-frame { background-size: 18px 18px; } +.game-resource-canvas-content { + position: relative; + display: grid; + align-content: start; + width: max-content; + min-width: 100%; + min-height: 100%; +} + +.game-resource-dependency-overlay { + position: absolute; + inset: 0; + z-index: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.game-resource-dependency-description { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.game-resource-dependency-edge, +.game-resource-dependency-edge path { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.game-resource-dependency-edge--reference { + stroke: #c45f20; + stroke-width: 2.4px; + opacity: 1; +} + +.game-resource-dependency-edge--task path { + stroke: #918b87; + stroke-width: 1.4px; + stroke-dasharray: 4 7; +} + +.game-resource-dependency-edge--task .game-resource-dependency-trunk { + stroke-width: 1.7px; + opacity: 0.88; +} + +.game-resource-dependency-edge--task .game-resource-dependency-branch { + opacity: 0.72; +} + +.game-resource-dependency-edge.is-cyclic, +.game-resource-dependency-edge.is-cyclic path { + stroke-dashoffset: 4; +} + +.game-resource-dependency-marker--reference path { + fill: #c45f20; + stroke-linejoin: round; +} + +.game-resource-dependency-marker--task path { + fill: #918b87; + stroke-linejoin: round; +} + .game-resource-section { + position: relative; + z-index: 1; display: grid; gap: 10px; min-width: 620px; @@ -4181,11 +4256,10 @@ iframe.preview-frame { color: #4e382f; text-align: left; box-shadow: 0 6px 18px rgb(96 62 47 / 6%); - cursor: grab; - touch-action: none; + cursor: pointer; + touch-action: manipulation; user-select: none; transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0); - will-change: transform; } .game-resource-card:hover, @@ -4196,13 +4270,11 @@ iframe.preview-frame { box-shadow: 0 8px 22px rgb(195 105 62 / 15%); } -.game-resource-card.is-dragging { - z-index: 2; - opacity: 0.72; - cursor: grabbing; +.game-resource-card.is-relation-version-binding { + border-color: #d87342; box-shadow: - 0 12px 28px rgb(195 105 62 / 24%), - 0 0 0 2px rgb(213 123 81 / 18%); + 0 8px 22px rgb(195 105 62 / 18%), + 0 0 0 2px rgb(216 115 66 / 14%); } .game-resource-card-icon { @@ -4233,38 +4305,16 @@ iframe.preview-frame { font-size: 9px; } -.game-resource-focus-layer { - position: fixed; - inset: 0; - z-index: 240; - pointer-events: none; -} - .game-resource-focus { - position: fixed; - left: 50%; - top: 50%; display: grid; grid-template-rows: auto minmax(0, 1fr); - width: min(520px, calc(100vw - 108px)); - max-height: min(620px, calc(100dvh - 190px)); + width: 100%; + min-width: 0; + min-height: 0; overflow: hidden; - border: 1px solid #dda07e; - border-radius: 16px; - background: rgb(255 251 247 / 97%); + background: #fffdfa; color: #563b31; - box-shadow: 0 20px 42px rgb(84 49 34 / 18%); outline: 0; - pointer-events: auto; - transform: translate(-50%, -50%); -} - -.game-resource-focus[style] { - transform: none; -} - -.game-resource-focus--art { - width: min(760px, calc(100vw - 140px)); } .game-resource-focus-titlebar { @@ -4273,16 +4323,9 @@ iframe.preview-frame { justify-content: space-between; gap: 12px; min-height: 58px; - padding: 10px 12px 10px 14px; + padding: 10px 14px 10px 16px; border-bottom: 1px solid #ead8cf; background: #fff8f3; - cursor: grab; - touch-action: none; - user-select: none; -} - -.game-resource-focus-titlebar:active { - cursor: grabbing; } .game-resource-focus-heading { @@ -4292,6 +4335,17 @@ iframe.preview-frame { min-width: 0; } +.game-resource-focus-heading > span:last-child { + display: grid; + min-width: 0; +} + +.game-resource-focus-heading small { + color: #a27764; + font-size: 9px; + font-weight: 700; +} + .game-resource-focus-icon { display: grid; width: 36px; @@ -4317,24 +4371,20 @@ iframe.preview-frame { align-content: start; gap: 6px; min-height: 0; - padding: 14px 16px 16px; - overflow: auto; + padding: 18px 20px 24px; + overflow: hidden; overscroll-behavior: contain; scrollbar-gutter: stable; } -.game-resource-focus span, -.game-resource-focus small { - overflow-wrap: anywhere; +.game-resource-focus--document .game-resource-focus-body, +.game-resource-focus--art .game-resource-focus-body, +.game-resource-focus--audio .game-resource-focus-body { + grid-template-rows: minmax(0, 1fr) auto; } -.game-resource-focus span { - font-size: 11px; -} - -.game-resource-focus small { - color: #92776c; - font-size: 10px; +.game-resource-focus--version .game-resource-focus-body { + overflow: auto; } .game-resource-focus-close { @@ -4351,10 +4401,43 @@ iframe.preview-frame { cursor: pointer; } +.game-resource-focus-metadata { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 18px; + margin: 0 0 8px; + padding: 12px 14px; + border: 1px solid #eaded8; + border-radius: 12px; + background: #fff; +} + +.game-resource-focus-metadata > div { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 8px; + min-width: 0; +} + +.game-resource-focus-metadata dt, +.game-resource-focus-metadata dd { + margin: 0; + overflow-wrap: anywhere; + font-size: 10px; +} + +.game-resource-focus-metadata dt { + color: #a08377; +} + +.game-resource-focus-metadata dd { + color: #5d4339; +} + .game-resource-image-preview { position: relative; display: grid; - height: min(420px, calc(100dvh - 360px)); + height: min(460px, calc(100dvh - 330px)); min-height: 260px; margin-bottom: 8px; overflow: hidden; @@ -4373,6 +4456,66 @@ iframe.preview-frame { place-items: center; } +.game-resource-media-preview, +.game-resource-audio-preview { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + margin-bottom: 8px; + overflow: hidden; + border: 1px solid #ead8cf; + border-radius: 12px; + background: #faf7f5; + place-items: center; +} + +.game-resource-media-preview { + background: linear-gradient(45deg, #f1ebe7 25%, transparent 25%), + linear-gradient(-45deg, #f1ebe7 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #f1ebe7 75%), + linear-gradient(-45deg, transparent 75%, #f1ebe7 75%), #faf7f5; + background-position: + 0 0, + 0 8px, + 8px -8px, + -8px 0; + background-size: 16px 16px; +} + +.game-resource-media-preview img, +.game-resource-media-preview video { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.game-resource-media-preview video { + width: 100%; + height: 100%; + background: #211d1b; +} + +.game-resource-audio-preview { + align-content: center; + padding: 28px; + background: linear-gradient(145deg, #fffaf6, #f5e7df); +} + +.game-resource-audio-preview audio { + width: min(620px, 100%); +} + +.game-resource-media-preview p, +.game-resource-audio-preview p { + margin: 0; + padding: 20px; + color: #92776c; + font-size: 12px; + text-align: center; +} + .game-resource-image-preview img { position: absolute; inset: 0; @@ -4391,17 +4534,19 @@ iframe.preview-frame { } .game-resource-document-body { - height: max-content; - min-height: 120px; + min-width: 0; + min-height: 0; margin-top: 6px; padding: 14px; border-radius: 10px; background: #f8f2ee; - color: #9e8579; - font-size: 11px; + color: #765c51; + font-size: 12px; line-height: 1.6; - overflow: hidden; + overflow: auto; overflow-wrap: anywhere; + overscroll-behavior: contain; + scrollbar-gutter: stable; user-select: text; } @@ -4483,6 +4628,19 @@ iframe.preview-frame { text-decoration: underline; } +.game-resource-document-link-text { + color: #8f5e49; + text-decoration: underline dotted; +} + +.game-resource-document-image-placeholder { + display: inline-block; + padding: 0.2em 0.45em; + border-radius: 5px; + background: #eaded7; + color: #806559; +} + .game-resource-document-body table { width: 100%; border-collapse: collapse; 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 new file mode 100644 index 000000000..38a51a13c --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx @@ -0,0 +1,676 @@ +import { + forwardRef, + useCallback, + useId, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import type { + ProjectResourceCanvasPosition, + ProjectResourceCanvasSection, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + RESOURCE_CANVAS_CARD_HEIGHT, + RESOURCE_CANVAS_CARD_WIDTH, +} from './resourceCanvasLayoutModel'; +import { + type ProjectResourceGraph, + type ProjectResourceReferenceEdge, + type ProjectResourceTaskFlow, +} from './resourceDependencyGraphModel'; + +type Point = { + x: number; + y: number; +}; + +type Rect = Point & { + width: number; + height: number; +}; + +type SectionOrigins = Partial>; + +type RectLookup = { + get(resourceId: string): Rect | undefined; +}; + +export type ResourceDependencyOverlayProps = { + graph: ProjectResourceGraph; + positions: readonly ProjectResourceCanvasPosition[]; + visibleResourceIds: ReadonlySet; +}; + +export type ResourceDependencyOverlayHandle = { + updateDragPreview: (preview: Point & { resourceId: string }) => void; + clearDragPreview: () => void; +}; + +type TaskFlowPathRefs = { + sourceBranches: Map; + targetBranches: Map; + trunk: SVGPathElement | null; +}; + +type TaskFlowSectionGeometry = NonNullable< + ReturnType +> & { + section: ProjectResourceCanvasSection; +}; + +const SECTION_SELECTOR = '[data-resource-section-plane]'; +const TASK_FLOW_HUB_GAP = 20; +const CONNECTION_MAX_HANDLE = 180; +const TASK_FLOW_BRANCH_MAX_HANDLE = 96; +const SELF_REFERENCE_LOOP_WIDTH = 56; +const SELF_REFERENCE_LOOP_ANCHOR_OFFSET = 18; +const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [ + 'document', + 'version', + 'art', + 'audio', +]; + +function pointsEqual(left: SectionOrigins, right: SectionOrigins) { + return RESOURCE_SECTIONS.every( + (section) => + left[section]?.x === right[section]?.x && + left[section]?.y === right[section]?.y, + ); +} + +function connectionPath(source: Point, target: Point) { + if (source.x === target.x && source.y === target.y) { + return `M ${source.x} ${source.y} C ${source.x + 48} ${source.y - 48}, ${ + source.x + 48 + } ${source.y + 48}, ${source.x} ${source.y + 1}`; + } + const direction = target.x >= source.x ? 1 : -1; + const bend = Math.min( + CONNECTION_MAX_HANDLE, + Math.max( + 32, + Math.abs(target.x - source.x) * 0.42 + + Math.abs(target.y - source.y) * 0.08, + ), + ); + return `M ${source.x} ${source.y} C ${source.x + direction * bend} ${ + source.y + }, ${target.x - direction * bend} ${target.y}, ${target.x} ${target.y}`; +} + +function taskFlowBranchPath(source: Point, target: Point) { + const horizontalDistance = Math.abs(target.x - source.x); + if (horizontalDistance < 1) { + const direction = target.y >= source.y ? 1 : -1; + const handle = Math.min( + TASK_FLOW_BRANCH_MAX_HANDLE, + Math.abs(target.y - source.y) * 0.5, + ); + return `M ${source.x} ${source.y} C ${source.x} ${ + source.y + direction * handle + }, ${target.x} ${target.y - direction * handle}, ${target.x} ${target.y}`; + } + const direction = target.x >= source.x ? 1 : -1; + const handle = Math.min( + TASK_FLOW_BRANCH_MAX_HANDLE, + horizontalDistance * 0.5, + ); + return `M ${source.x} ${source.y} C ${source.x + direction * handle} ${ + source.y + }, ${target.x - direction * handle} ${target.y}, ${target.x} ${target.y}`; +} + +function rectCenter(rect: Rect): Point { + return { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + }; +} + +function average(values: readonly number[]) { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function rectAnchor(rect: Rect, direction: 1 | -1): Point { + return { + x: direction === 1 ? rect.x + rect.width : rect.x, + y: rect.y + rect.height / 2, + }; +} + +function referenceGeometry( + edge: ProjectResourceReferenceEdge, + rectByResourceId: RectLookup, +) { + const sourceRect = rectByResourceId.get(edge.sourceResourceId); + const targetRect = rectByResourceId.get(edge.targetResourceId); + if (!sourceRect || !targetRect) { + return null; + } + if (edge.sourceResourceId === edge.targetResourceId) { + const anchorX = sourceRect.x + sourceRect.width; + const centerY = sourceRect.y + sourceRect.height / 2; + const sourceY = centerY + SELF_REFERENCE_LOOP_ANCHOR_OFFSET; + const targetY = centerY - SELF_REFERENCE_LOOP_ANCHOR_OFFSET; + const loopX = anchorX + SELF_REFERENCE_LOOP_WIDTH; + return { + path: `M ${anchorX} ${sourceY} C ${loopX} ${sourceY}, ${loopX} ${targetY}, ${anchorX} ${targetY}`, + selfLoop: true, + }; + } + const sourceCenter = rectCenter(sourceRect); + const targetCenter = rectCenter(targetRect); + const direction: 1 | -1 = targetCenter.x >= sourceCenter.x ? 1 : -1; + const source = rectAnchor(sourceRect, direction); + const target = rectAnchor(targetRect, direction === 1 ? -1 : 1); + return { + path: connectionPath(source, target), + selfLoop: false, + }; +} + +function taskFlowGeometry( + flow: ProjectResourceTaskFlow, + rectByResourceId: RectLookup, +) { + const sourceRects = flow.sourceResourceIds.flatMap((resourceId) => { + const rect = rectByResourceId.get(resourceId); + return rect ? [{ resourceId, rect }] : []; + }); + const targetRects = flow.targetResourceIds.flatMap((resourceId) => { + const rect = rectByResourceId.get(resourceId); + return rect ? [{ resourceId, rect }] : []; + }); + if (sourceRects.length === 0 || targetRects.length === 0) { + return null; + } + const sourceCenterX = average( + sourceRects.map(({ rect }) => rectCenter(rect).x), + ); + const targetCenterX = average( + targetRects.map(({ rect }) => rectCenter(rect).x), + ); + const direction: 1 | -1 = targetCenterX >= sourceCenterX ? 1 : -1; + const sourceAnchors = sourceRects.map(({ resourceId, rect }) => ({ + resourceId, + point: rectAnchor(rect, direction), + })); + const targetAnchors = targetRects.map(({ resourceId, rect }) => ({ + resourceId, + point: rectAnchor(rect, direction === 1 ? -1 : 1), + })); + const sourceHub: Point = { + x: + (direction === 1 + ? Math.max(...sourceAnchors.map(({ point }) => point.x)) + : Math.min(...sourceAnchors.map(({ point }) => point.x))) + + direction * TASK_FLOW_HUB_GAP, + y: average(sourceAnchors.map(({ point }) => point.y)), + }; + const targetHub: Point = { + x: + (direction === 1 + ? Math.min(...targetAnchors.map(({ point }) => point.x)) + : Math.max(...targetAnchors.map(({ point }) => point.x))) - + direction * TASK_FLOW_HUB_GAP, + y: average(targetAnchors.map(({ point }) => point.y)), + }; + return { sourceAnchors, targetAnchors, sourceHub, targetHub }; +} + +function taskFlowSectionGeometries( + flow: ProjectResourceTaskFlow, + rectByResourceId: RectLookup, + sectionByResourceId: ReadonlyMap, +): TaskFlowSectionGeometry[] { + return RESOURCE_SECTIONS.flatMap((section) => { + const geometry = taskFlowGeometry( + { + ...flow, + sourceResourceIds: flow.sourceResourceIds.filter( + (resourceId) => sectionByResourceId.get(resourceId) === section, + ), + targetResourceIds: flow.targetResourceIds.filter( + (resourceId) => sectionByResourceId.get(resourceId) === section, + ), + }, + rectByResourceId, + ); + return geometry ? [{ ...geometry, section }] : []; + }); +} + +function taskFlowRenderKey( + flowId: string, + section: ProjectResourceCanvasSection, +) { + return `${flowId}\n${section}`; +} + +export const ResourceDependencyOverlay = forwardRef< + ResourceDependencyOverlayHandle, + ResourceDependencyOverlayProps +>(function ResourceDependencyOverlay( + { graph, positions, visibleResourceIds }, + ref, +) { + const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, ''); + const overlayRef = useRef(null); + const referencePathRefs = useRef(new Map()); + const taskFlowPathRefs = useRef(new Map()); + const activeDragPreviewRef = useRef<(Point & { resourceId: string }) | null>( + null, + ); + const graphRef = useRef(graph); + const positionByResourceIdRef = useRef( + new Map(positions.map((position) => [position.resourceId, position])), + ); + const rectByResourceIdRef = useRef>(new Map()); + const [sectionOrigins, setSectionOrigins] = useState({}); + + useLayoutEffect(() => { + const canvas = overlayRef.current?.parentElement; + if (!canvas) { + return undefined; + } + let frameId: number | null = null; + const measure = () => { + frameId = null; + const canvasRect = canvas.getBoundingClientRect(); + const next: SectionOrigins = {}; + canvas + .querySelectorAll(SECTION_SELECTOR) + .forEach((plane) => { + const section = plane.dataset.resourceSectionPlane as + | ProjectResourceCanvasSection + | undefined; + if (!section) { + return; + } + const planeRect = plane.getBoundingClientRect(); + next[section] = { + x: planeRect.left - canvasRect.left, + y: planeRect.top - canvasRect.top, + }; + }); + setSectionOrigins((current) => + pointsEqual(current, next) ? current : next, + ); + }; + const scheduleMeasure = () => { + if (frameId !== null) { + return; + } + frameId = window.requestAnimationFrame(measure); + }; + measure(); + const ResizeObserverClass = window.ResizeObserver; + const observer = ResizeObserverClass + ? new ResizeObserverClass(scheduleMeasure) + : null; + observer?.observe(canvas); + canvas + .querySelectorAll(SECTION_SELECTOR) + .forEach((plane) => observer?.observe(plane)); + window.addEventListener('resize', scheduleMeasure); + return () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + observer?.disconnect(); + window.removeEventListener('resize', scheduleMeasure); + }; + }, []); + + const rectByResourceId = useMemo(() => { + const result = new Map(); + for (const position of positions) { + if ( + !graph.resourceIds.has(position.resourceId) || + !visibleResourceIds.has(position.resourceId) + ) { + continue; + } + const origin = sectionOrigins[position.section]; + if (!origin) { + continue; + } + result.set(position.resourceId, { + x: origin.x + position.x, + y: origin.y + position.y, + width: RESOURCE_CANVAS_CARD_WIDTH, + height: RESOURCE_CANVAS_CARD_HEIGHT, + }); + } + return result; + }, [graph.resourceIds, positions, sectionOrigins, visibleResourceIds]); + const sectionByResourceId = useMemo( + () => + new Map( + positions.map((position) => [position.resourceId, position.section]), + ), + [positions], + ); + graphRef.current = graph; + positionByResourceIdRef.current = new Map( + positions.map((position) => [position.resourceId, position]), + ); + rectByResourceIdRef.current = rectByResourceId; + + const taskFlowRenderEntries = useMemo( + () => + graph.taskFlows.flatMap((flow) => + taskFlowSectionGeometries( + flow, + rectByResourceId, + sectionByResourceId, + ).map((geometry) => ({ + flow, + geometry, + renderKey: taskFlowRenderKey(flow.id, geometry.section), + })), + ), + [graph.taskFlows, rectByResourceId, sectionByResourceId], + ); + + const renderTaskFlows = useMemo( + () => + taskFlowRenderEntries.map(({ flow, geometry, renderKey }) => { + const className = `game-resource-dependency-edge game-resource-dependency-edge--task${ + flow.cyclic ? ' is-cyclic' : '' + }`; + return ( + + {`任务流转:${flow.sourceTaskId} → ${flow.targetTaskId}${ + flow.cyclic ? '(检测到依赖环)' : '' + }`} + {geometry.sourceAnchors.map(({ resourceId, point }) => ( + { + let refs = taskFlowPathRefs.current.get(renderKey); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(renderKey, refs); + } + if (node) { + refs.sourceBranches.set(resourceId, node); + } else { + refs.sourceBranches.delete(resourceId); + } + }} + key={`source:${resourceId}`} + className="game-resource-dependency-branch" + data-branch-side="source" + data-resource-id={resourceId} + d={taskFlowBranchPath(point, geometry.sourceHub)} + /> + ))} + { + let refs = taskFlowPathRefs.current.get(renderKey); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(renderKey, refs); + } + refs.trunk = node; + }} + className="game-resource-dependency-trunk" + d={connectionPath(geometry.sourceHub, geometry.targetHub)} + /> + {geometry.targetAnchors.map(({ resourceId, point }) => ( + { + let refs = taskFlowPathRefs.current.get(renderKey); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(renderKey, refs); + } + if (node) { + refs.targetBranches.set(resourceId, node); + } else { + refs.targetBranches.delete(resourceId); + } + }} + key={`target:${resourceId}`} + className="game-resource-dependency-branch" + data-branch-side="target" + data-resource-id={resourceId} + d={taskFlowBranchPath(geometry.targetHub, point)} + markerEnd={`url(#${markerPrefix}-task-flow-arrow)`} + /> + ))} + + ); + }), + [markerPrefix, taskFlowRenderEntries], + ); + + const renderReferenceEdges = useMemo( + () => + graph.referenceEdges.map((edge) => { + const geometry = referenceGeometry(edge, rectByResourceId); + if (!geometry) { + return null; + } + const className = `game-resource-dependency-edge game-resource-dependency-edge--reference${ + edge.cyclic ? ' is-cyclic' : '' + }`; + return ( + { + if (node) { + referencePathRefs.current.set(edge.id, node); + } else { + referencePathRefs.current.delete(edge.id); + } + }} + key={edge.id} + className={className} + data-edge-kind="asset-reference" + data-edge-id={edge.id} + data-source-resource-id={edge.sourceResourceId} + data-target-resource-id={edge.targetResourceId} + data-cyclic={edge.cyclic || undefined} + data-self-loop={geometry.selfLoop || undefined} + d={geometry.path} + markerEnd={`url(#${markerPrefix}-asset-reference-arrow)`} + > + {`资源引用${edge.cyclic ? '(检测到依赖环)' : ''}`} + + ); + }), + [graph.referenceEdges, markerPrefix, rectByResourceId], + ); + + useLayoutEffect(() => { + const activeRenderKeys = new Set( + taskFlowRenderEntries.map((entry) => entry.renderKey), + ); + for (const renderKey of taskFlowPathRefs.current.keys()) { + if (!activeRenderKeys.has(renderKey)) { + taskFlowPathRefs.current.delete(renderKey); + } + } + }, [taskFlowRenderEntries]); + + const updateAffectedGeometry = useCallback( + ( + affectedResourceIds: ReadonlySet, + dragPreview: (Point & { resourceId: string }) | null, + ) => { + const currentGraph = graphRef.current; + const currentRects = rectByResourceIdRef.current; + const dragBasePosition = dragPreview + ? positionByResourceIdRef.current.get(dragPreview.resourceId) + : undefined; + const rectLookup = { + get(resourceId: string) { + const rect = currentRects.get(resourceId); + if (!rect) { + return undefined; + } + return dragPreview?.resourceId === resourceId + ? { + ...rect, + x: rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x, + y: rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y, + } + : rect; + }, + }; + const affectedEdgeIds = new Set(); + for (const resourceId of affectedResourceIds) { + const index = currentGraph.connectionIndex.get(resourceId); + index?.referenceEdgeIds.forEach((edgeId) => + affectedEdgeIds.add(edgeId), + ); + index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId)); + } + for (const edgeId of affectedEdgeIds) { + const referenceEdge = currentGraph.referenceEdgeById.get(edgeId); + if (referenceEdge) { + const geometry = referenceGeometry(referenceEdge, rectLookup); + const path = referencePathRefs.current.get(edgeId); + if (geometry && path) { + path.setAttribute('d', geometry.path); + } + continue; + } + const flow = currentGraph.taskFlowById.get(edgeId); + if (!flow) { + continue; + } + const sectionByResourceId = new Map( + Array.from( + positionByResourceIdRef.current.values(), + (position) => [position.resourceId, position.section] as const, + ), + ); + for (const geometry of taskFlowSectionGeometries( + flow, + rectLookup, + sectionByResourceId, + )) { + const paths = taskFlowPathRefs.current.get( + taskFlowRenderKey(flow.id, geometry.section), + ); + if (!paths) { + continue; + } + geometry.sourceAnchors.forEach(({ resourceId, point }) => { + paths.sourceBranches + .get(resourceId) + ?.setAttribute( + 'd', + taskFlowBranchPath(point, geometry.sourceHub), + ); + }); + paths.trunk?.setAttribute( + 'd', + connectionPath(geometry.sourceHub, geometry.targetHub), + ); + geometry.targetAnchors.forEach(({ resourceId, point }) => { + paths.targetBranches + .get(resourceId) + ?.setAttribute( + 'd', + taskFlowBranchPath(geometry.targetHub, point), + ); + }); + } + } + }, + [], + ); + + useImperativeHandle( + ref, + () => ({ + updateDragPreview(preview) { + const affectedResourceIds = new Set(); + if (activeDragPreviewRef.current) { + affectedResourceIds.add(activeDragPreviewRef.current.resourceId); + } + affectedResourceIds.add(preview.resourceId); + activeDragPreviewRef.current = preview; + updateAffectedGeometry(affectedResourceIds, preview); + }, + clearDragPreview() { + const active = activeDragPreviewRef.current; + activeDragPreviewRef.current = null; + if (active) { + updateAffectedGeometry(new Set([active.resourceId]), null); + } + }, + }), + [updateAffectedGeometry], + ); + + return ( + + ); +}); 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 dd5e99492..134591abf 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 @@ -18,10 +18,11 @@ import { } from 'lucide-react'; import { type CSSProperties, - type PointerEvent as ReactPointerEvent, + memo, type ReactNode, useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -34,64 +35,55 @@ import type { GameCreationAppAgentGroup, GameCreationAppManifest, GameCreationAppPreviewState, - GameCreationAppTaskState, ProjectResourceCanvasLayoutMode, - ProjectResourceCanvasSection, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; +import { resourceCanvasSectionExtent } from './resourceCanvasLayoutModel'; import { - RESOURCE_CANVAS_DRAG_THRESHOLD, - resourceCanvasSectionExtent, -} from './resourceCanvasLayoutModel'; + EMPTY_PROJECT_RESOURCE_GRAPH, + normalizeProjectResourceGraph, + type ProjectResourceGraph, + type ProjectResourceGraphNodeInput, + type ProjectResourceGraphReadModel, +} from './resourceDependencyGraphModel'; +import { ResourceDependencyOverlay } from './ResourceDependencyOverlay'; +import { + type ProjectAgentResultSummary, + type ProjectAttachmentResult, + type ProjectResource, + type ProjectResourceCategory, + projectResourcesFromReadModels, +} from './resourceProjectionModel'; import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout'; -type AttachmentResult = { - fileName: string; - mediaType: string; - localPath?: string; - status: 'imported' | 'failed'; - error?: string; -}; +export type { + ProjectAgentResultSummary, + ProjectVersionResourceSummary, +} from './resourceProjectionModel'; -type ResourceCategory = ProjectResourceCanvasSection; +type ResourceCategory = ProjectResourceCategory; type ResourceSortMode = ProjectResourceCanvasLayoutMode; type WorkbenchMode = 'resources' | 'run'; type ApprovalMode = 'strict' | 'risk' | 'none'; -type Point = { - x: number; - y: number; -}; - -type ResourceCardDrag = { - pointerId: number; - resourceId: string; - section: ResourceCategory; - startClientX: number; - startClientY: number; - startX: number; - startY: number; - moved: boolean; -}; - -type ProjectResource = { - id: string; - category: ResourceCategory; - subtype: string; - label: string; +type LocalProjectImagePreview = { path: string; mediaType: string; - sourceLabel: string; - taskTitle: string | null; - dependencies: string[]; - dependencyDepth: number; - content?: string; + byteLen: number; + dataUrl: string; }; -type LocalProjectImagePreview = { +type LocalProjectTextPreview = { + path: string; + mediaType: string; + byteLen: number; + content: string; +}; + +type LocalProjectMediaPreview = { path: string; mediaType: string; byteLen: number; @@ -108,14 +100,25 @@ type ImagePreviewState = } | { status: 'failed'; resourceId: string; error: string }; -export type ProjectAgentResultSummary = { - agentId: string; - runId: string; - label: string; - title: string; - content: string; - updatedAt: number; -}; +type TextPreviewState = + | { status: 'idle'; resourceId: null } + | { status: 'loading'; resourceId: string } + | { + status: 'loaded'; + resourceId: string; + preview: LocalProjectTextPreview; + } + | { status: 'failed'; resourceId: string; error: string }; + +type MediaPreviewState = + | { status: 'idle'; resourceId: null } + | { status: 'loading'; resourceId: string } + | { + status: 'loaded'; + resourceId: string; + preview: LocalProjectMediaPreview; + } + | { status: 'failed'; resourceId: string; error: string }; export type ProjectAgentRuntimeSummary = { group: GameCreationAppAgentGroup; @@ -131,6 +134,7 @@ export type ProjectAgentRuntimeSummary = { const emptyProjectAgentRuntimeSummaries: ProjectAgentRuntimeSummary[] = []; const emptyProjectAgentResults: ProjectAgentResultSummary[] = []; +const RESOURCE_DEPENDENCY_VISUAL_GUTTER = 64; type AgentSummary = ProjectAgentRuntimeSummary; @@ -138,7 +142,7 @@ export type ProjectDevelopmentViewProps = { projectName: string; projectPath: string; manifest: GameCreationAppManifest; - attachments: AttachmentResult[]; + attachments: ProjectAttachmentResult[]; recentRunStatus: string | null; recentRunStopReason: string | null; preview?: GameCreationAppPreviewState | null; @@ -196,36 +200,6 @@ const approvalOptions: Array<{ }, ]; -function fileName(path: string) { - return path.split(/[\\/]/).filter(Boolean).pop() || path; -} - -function categoryFromResource(path: string, mediaType: string) { - const normalizedPath = path.toLowerCase(); - const normalizedMediaType = mediaType.toLowerCase(); - if ( - normalizedMediaType.startsWith('audio/') || - /\.(mp3|wav|ogg|m4a|aac|flac)$/u.test(normalizedPath) - ) { - return 'audio' as const; - } - if ( - normalizedMediaType.startsWith('image/') || - normalizedMediaType.startsWith('video/') || - /\.(png|jpe?g|webp|gif|svg|mp4|webm)$/u.test(normalizedPath) - ) { - return 'art' as const; - } - if ( - normalizedMediaType.includes('json') || - normalizedMediaType.startsWith('text/') || - /\.(md|txt|json|ya?ml|toml)$/u.test(normalizedPath) - ) { - return 'document' as const; - } - return 'version' as const; -} - function isRasterImageResource(resource: ProjectResource) { const mediaType = resource.mediaType.toLowerCase(); return ( @@ -235,6 +209,77 @@ function isRasterImageResource(resource: ProjectResource) { ); } +function isExtendedArtMediaResource(resource: ProjectResource) { + const mediaType = resource.mediaType.toLowerCase(); + return ( + resource.category === 'art' && + (mediaType === 'image/svg+xml' || + mediaType.startsWith('video/') || + /\.(gif|svg|avif|bmp|mp4|webm|mov)$/iu.test(resource.path)) + ); +} + +function mediaPreviewErrorMessage(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('项目权限策略要求用户确认')) { + return '当前项目策略要求先确认读取资源'; + } + if (message.includes('项目权限策略拒绝执行')) { + return '当前项目策略不允许读取资源'; + } + if (message.includes('不能超过')) { + return message; + } + if (message.includes('UTF-8') || message.includes('只支持')) { + return message; + } + if (message.includes('脚本或外部资源引用')) { + return message; + } + if (message.includes('发生漂移') || message.includes('发生替换')) { + return '资源读取期间发生变化,请关闭后重试'; + } + return '资源暂时无法读取,请关闭后重试'; +} + +function formatMediaDuration(duration: number | null) { + if (duration === null || !Number.isFinite(duration) || duration < 0) { + return '载入后显示'; + } + const totalSeconds = Math.floor(duration); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function formatVersionCreatedAt(createdAt: number) { + const value = new Date(createdAt); + return Number.isFinite(value.getTime()) + ? value.toLocaleString('zh-CN') + : String(createdAt); +} + +function SafeProjectMarkdown({ content }: { content: string }) { + return ( + ( + {children} + ), + img: ({ alt }) => ( + + {alt ? `图片:${alt}` : '文档图片已省略'} + + ), + }} + > + {content} + + ); +} + function imagePreviewErrorMessage(error: unknown) { const message = error instanceof Error ? error.message : String(error); if (message.includes('项目权限策略要求用户确认')) { @@ -264,135 +309,6 @@ function imagePreviewErrorMessage(error: unknown) { return '图片暂时无法读取,请关闭后重试'; } -function taskDependencyDepth( - task: GameCreationAppTaskState, - taskById: Map, - seen = new Set(), -): number { - if (seen.has(task.id) || task.dependencies.length === 0) { - return 0; - } - const nextSeen = new Set(seen).add(task.id); - return ( - 1 + - Math.max( - 0, - ...task.dependencies.map((dependency) => { - const dependencyTask = taskById.get(dependency); - return dependencyTask - ? taskDependencyDepth(dependencyTask, taskById, nextSeen) - : 0; - }), - ) - ); -} - -function resourcesFromProject( - manifest: GameCreationAppManifest, - attachments: AttachmentResult[], - agentResults: ProjectAgentResultSummary[], -) { - const taskById = new Map(manifest.tasks.map((task) => [task.id, task])); - const resources: ProjectResource[] = []; - - for (const task of manifest.tasks) { - if (task.status !== 'completed') { - continue; - } - for (const path of task.artifacts) { - const category = categoryFromResource(path, ''); - resources.push({ - id: `task:${task.id}:${path}`, - category, - subtype: 'task-artifact', - label: fileName(path), - path, - mediaType: category === 'document' ? '项目文档' : '项目产物', - sourceLabel: '任务产物', - taskTitle: task.title, - dependencies: task.dependencies, - dependencyDepth: taskDependencyDepth(task, taskById), - }); - } - } - - for (const asset of manifest.assets) { - const task = asset.source.taskId - ? taskById.get(asset.source.taskId) - : undefined; - const isPendingUiPrototype = - asset.kind === 'ui-prototype' && - taskById.get('design-foundation')?.status !== 'completed'; - resources.push({ - id: `asset:${asset.id}`, - category: categoryFromResource(asset.localPath, asset.mediaType), - subtype: asset.kind, - label: `${fileName(asset.localPath)}${ - isPendingUiPrototype ? '(待视觉验收)' : '' - }`, - path: asset.localPath, - mediaType: asset.mediaType, - sourceLabel: - isPendingUiPrototype && asset.source.kind === 'canvas' - ? '画板 · 候选界面图' - : asset.source.kind === 'canvas' - ? '画板' - : asset.source.kind === 'generated' - ? 'Agent 生成' - : '用户上传', - taskTitle: task?.title ?? null, - dependencies: task?.dependencies ?? [], - dependencyDepth: task ? taskDependencyDepth(task, taskById) : 0, - }); - } - - for (const attachment of attachments) { - if (attachment.status !== 'imported' || !attachment.localPath) { - continue; - } - resources.push({ - id: `attachment:${attachment.localPath}`, - category: categoryFromResource( - attachment.localPath, - attachment.mediaType, - ), - subtype: 'attachment', - label: attachment.fileName, - path: attachment.localPath, - mediaType: attachment.mediaType || '未知媒体类型', - sourceLabel: '用户上传', - taskTitle: null, - dependencies: [], - dependencyDepth: 0, - }); - } - - for (const result of agentResults) { - resources.push({ - id: `agent-result:${result.agentId}:${result.runId}`, - category: 'document', - subtype: 'agent-result', - label: result.title, - path: `专业 Agent 文本回执 · ${result.label}`, - mediaType: 'Agent 历史文本回执', - sourceLabel: `历史成果 · ${result.label}`, - taskTitle: null, - dependencies: [], - dependencyDepth: 0, - content: result.content, - }); - } - - const uniqueByPath = new Map(); - for (const resource of resources) { - const existing = uniqueByPath.get(resource.path); - if (!existing || resource.id.startsWith('asset:')) { - uniqueByPath.set(resource.path, resource); - } - } - return Array.from(uniqueByPath.values()); -} - function summarizeAgent( manifest: GameCreationAppManifest, group: AgentSummary['group'], @@ -436,60 +352,54 @@ function summarizeAgent( }; } -function ResourceCard({ +const ResourceCard = memo(function ResourceCard({ resource, selected, - dragging, + relationState, x, y, onSelect, - onPointerDown, - onPointerMove, - onPointerUp, - onPointerCancel, }: { resource: ProjectResource; selected: boolean; - dragging: boolean; + relationState: 'version-binding' | null; x: number; y: number; - onSelect: () => void; - onPointerDown: (event: ReactPointerEvent) => void; - onPointerMove: (event: ReactPointerEvent) => void; - onPointerUp: (event: ReactPointerEvent) => void; - onPointerCancel: (event: ReactPointerEvent) => void; + onSelect: (resourceId: string) => void; }) { const Icon = categoryIcons[resource.category]; return ( ); -} +}); export default function ProjectDevelopmentView({ projectName, @@ -507,35 +417,38 @@ export default function ProjectDevelopmentView({ const [selectedResourceId, setSelectedResourceId] = useState( null, ); + const [focusedResourceId, setFocusedResourceId] = useState( + null, + ); const [approvalMode, setApprovalMode] = useState('strict'); const [approvalDialogOpen, setApprovalDialogOpen] = useState(false); const [approvalNotice, setApprovalNotice] = useState(''); const [showAllAgentGroups, setShowAllAgentGroups] = useState(false); const [runPlaying, setRunPlaying] = useState(false); const [activeSlice, setActiveSlice] = useState(0); - const [draggedResourceId, setDraggedResourceId] = useState( - null, - ); - const [resourceDragPreview, setResourceDragPreview] = useState< - (Point & { resourceId: string }) | null - >(null); - const [resourceDialogPosition, setResourceDialogPosition] = - useState(null); const [imagePreview, setImagePreview] = useState({ status: 'idle', resourceId: null, }); - const workbenchRef = useRef(null); - const stageRef = useRef(null); - const dockRef = useRef(null); - const resourceDialogRef = useRef(null); - const resourceCardDragRef = useRef(null); - const suppressResourceClickRef = useRef(null); - const resourceDialogDragRef = useRef<{ - pointerId: number; - offsetX: number; - offsetY: number; - } | null>(null); + const [textPreview, setTextPreview] = useState({ + status: 'idle', + resourceId: null, + }); + const [mediaPreview, setMediaPreview] = useState({ + status: 'idle', + resourceId: null, + }); + const [mediaDuration, setMediaDuration] = useState(null); + const resourceCanvasRef = useRef(null); + const resourceSearchRef = useRef(null); + const resourceFocusRef = useRef(null); + const resourceFocusTriggerIdRef = useRef(null); + const previousFocusedResourceIdRef = useRef(null); + const resourceFocusProjectPathRef = useRef(projectPath); + const suppressResourceFocusRestoreRef = useRef(false); + const resourceListScrollRef = useRef({ left: 0, top: 0 }); + const restoreResourceListScrollRef = useRef(false); + const dependencyDescriptionId = useId(); const preview = previewOverride ?? manifest.preview ?? null; const embeddedPreviewUrl = resolveEmbeddedPreviewUrl(preview); @@ -544,40 +457,295 @@ export default function ProjectDevelopmentView({ manifest.tasks.some( (task) => task.id === 'code-prototype' && task.status === 'completed', ); - const resources = useMemo( - () => resourcesFromProject(manifest, attachments, agentResults), + const projectedResources = useMemo( + () => projectResourcesFromReadModels(manifest, attachments, agentResults), [agentResults, attachments, manifest], ); + const resourceGraphInputs = useMemo( + () => + projectedResources.map((resource) => ({ + resourceId: resource.id, + manifestAssetId: resource.manifestAssetId, + producerTaskId: resource.producerTaskId, + })), + [projectedResources], + ); + const resourceGraphScopeKey = useMemo( + () => + JSON.stringify([projectPath, manifest.projectId, resourceGraphInputs]), + [manifest.projectId, projectPath, resourceGraphInputs], + ); + const [resourceGraphState, setResourceGraphState] = useState<{ + scopeKey: string; + status: 'idle' | 'loading' | 'ready' | 'failed'; + graph: ProjectResourceGraph; + }>({ + scopeKey: '', + status: 'idle', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + + useEffect(() => { + let cancelled = false; + if (sortMode !== 'dependency') { + setResourceGraphState({ + scopeKey: '', + status: 'idle', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + return () => { + cancelled = true; + }; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'failed', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + return () => { + cancelled = true; + }; + } + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'loading', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + void invoke( + 'read_local_project_resource_graph', + { + projectPath, + expectedProjectId: manifest.projectId, + resources: resourceGraphInputs, + }, + ) + .then((readModel) => { + if (!cancelled) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'ready', + graph: normalizeProjectResourceGraph(readModel), + }); + } + }) + .catch(() => { + if (!cancelled) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'failed', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + } + }); + return () => { + cancelled = true; + }; + }, [ + manifest.projectId, + projectPath, + resourceGraphInputs, + resourceGraphScopeKey, + sortMode, + ]); + + const resourceGraphScopeMatches = + resourceGraphState.scopeKey === resourceGraphScopeKey; + const resourceGraphReady = + resourceGraphScopeMatches && resourceGraphState.status === 'ready'; + const resourceGraph = resourceGraphReady + ? resourceGraphState.graph + : EMPTY_PROJECT_RESOURCE_GRAPH; + const resourceGraphInitializationReady = + sortMode !== 'dependency' || + (resourceGraphScopeMatches && + (resourceGraphState.status === 'ready' || + resourceGraphState.status === 'failed')); + const manifestTaskById = useMemo( + () => new Map(manifest.tasks.map((task) => [task.id, task])), + [manifest.tasks], + ); + const resources = useMemo( + () => + projectedResources.map((resource) => { + const producerTaskId = + resourceGraph.producerTaskIdByResourceId.get(resource.id) ?? + resource.producerTaskId; + const producerTask = producerTaskId + ? manifestTaskById.get(producerTaskId) + : undefined; + return { + ...resource, + taskTitle: producerTask?.title ?? resource.taskTitle, + producerTaskId, + dependencies: producerTask?.dependencies ?? resource.dependencies, + dependencyDepth: + resourceGraph.dependencyDepthByResourceId.get(resource.id) ?? 0, + }; + }), + [manifestTaskById, projectedResources, resourceGraph], + ); const { layout: resourceLayout, notice: resourceLayoutNotice, saving: resourceLayoutSaving, - commitPosition: commitResourcePosition, } = useProjectResourceCanvasLayout({ projectPath, projectId: manifest.projectId, mode: sortMode, resources, + initializationReady: resourceGraphInitializationReady, + rederiveAutomaticPositions: sortMode === 'dependency' && resourceGraphReady, }); - const resourcePositionById = new Map( - resourceLayout.positions.map((position) => [position.resourceId, position]), + const resourcePositionById = useMemo( + () => + new Map( + resourceLayout.positions.map((position) => [ + position.resourceId, + position, + ]), + ), + [resourceLayout.positions], ); + const selectedVersionBindingResourceIds = useMemo(() => { + const selectedVersion = resources.find( + (resource) => resource.id === selectedResourceId, + )?.version; + if (!selectedVersion) { + return new Set(); + } + const boundManifestAssetIds = new Set( + selectedVersion.resourceBindings.map((binding) => binding.resourceId), + ); + return new Set( + resources + .filter( + (resource) => + resource.manifestAssetId !== null && + boundManifestAssetIds.has(resource.manifestAssetId), + ) + .map((resource) => resource.id), + ); + }, [resources, selectedResourceId]); const normalizedSearch = searchText.trim().toLowerCase(); - const visibleResources = resources.filter((resource) => - normalizedSearch - ? [ - resource.label, - resource.path, - resource.mediaType, - resource.taskTitle ?? '', - ].some((value) => value.toLowerCase().includes(normalizedSearch)) - : true, + const visibleResources = useMemo( + () => + resources.filter((resource) => + normalizedSearch + ? [ + resource.label, + resource.path, + resource.mediaType, + resource.taskTitle ?? '', + ].some((value) => value.toLowerCase().includes(normalizedSearch)) + : true, + ), + [normalizedSearch, resources], + ); + const visibleResourceIds = useMemo( + () => new Set(visibleResources.map((resource) => resource.id)), + [visibleResources], + ); + const visibleResourcesByCategory = useMemo( + () => + new Map( + categoryOrder.map((category) => [ + category, + visibleResources.filter((resource) => resource.category === category), + ]), + ), + [visibleResources], + ); + const dependencyRelationshipDescriptions = useMemo(() => { + if (sortMode !== 'dependency') { + return []; + } + const labelByResourceId = new Map( + resources.map((resource) => [resource.id, resource.label]), + ); + const categoryByResourceId = new Map( + resources.map((resource) => [resource.id, resource.category]), + ); + const descriptions = new Set( + resourceGraph.referenceEdges.flatMap((edge) => + visibleResourceIds.has(edge.sourceResourceId) && + visibleResourceIds.has(edge.targetResourceId) + ? [ + `${labelByResourceId.get(edge.targetResourceId) ?? edge.targetResourceId} 引用 ${ + labelByResourceId.get(edge.sourceResourceId) ?? + edge.sourceResourceId + }${edge.cyclic ? ',检测到依赖环' : ''}`, + ] + : [], + ), + ); + for (const flow of resourceGraph.taskFlows) { + for (const category of categoryOrder) { + const sourceLabels = flow.sourceResourceIds + .filter( + (resourceId) => + visibleResourceIds.has(resourceId) && + categoryByResourceId.get(resourceId) === category, + ) + .map((resourceId) => labelByResourceId.get(resourceId) ?? resourceId); + const targetLabels = flow.targetResourceIds + .filter( + (resourceId) => + visibleResourceIds.has(resourceId) && + categoryByResourceId.get(resourceId) === category, + ) + .map((resourceId) => labelByResourceId.get(resourceId) ?? resourceId); + if (sourceLabels.length > 0 && targetLabels.length > 0) { + descriptions.add( + `任务 ${flow.sourceTaskId} 的资源 ${sourceLabels.join('、')} 流向任务 ${ + flow.targetTaskId + } 的资源 ${targetLabels.join('、')}${flow.cyclic ? ',检测到依赖环' : ''}`, + ); + } + } + } + return Array.from(descriptions); + }, [resourceGraph, resources, sortMode, visibleResourceIds]); + const resourcePositionsByCategory = useMemo( + () => + new Map( + categoryOrder.map((category) => [ + category, + resourceLayout.positions.filter( + (position) => position.section === category, + ), + ]), + ), + [resourceLayout.positions], + ); + const resourceBaseExtentByCategory = useMemo( + () => + new Map( + categoryOrder.map((category) => [ + category, + resourceCanvasSectionExtent( + resourcePositionsByCategory.get(category) ?? [], + ), + ]), + ), + [resourcePositionsByCategory], ); const selectedResource = resources.find((resource) => resource.id === selectedResourceId) ?? null; - const selectedResourceIsImage = Boolean( - selectedResource && isRasterImageResource(selectedResource), + const focusedResource = + resources.find((resource) => resource.id === focusedResourceId) ?? null; + const focusedResourcePath = focusedResource?.path ?? null; + const focusedResourceCategory = focusedResource?.category ?? null; + const focusedResourceContent = focusedResource?.content; + const focusedResourceMediaType = focusedResource?.mediaType ?? null; + const focusedResourceIsImage = Boolean( + focusedResource && isRasterImageResource(focusedResource), ); + const focusedResourceIsExtendedArtMedia = Boolean( + focusedResource && isExtendedArtMediaResource(focusedResource), + ); + const focusedResourceIsAudio = focusedResource?.category === 'audio'; const hasRegisteredArtImageAssets = manifest.assets.some( (asset) => asset.kind === 'art-spritesheet' && asset.mediaType.startsWith('image/'), @@ -613,35 +781,68 @@ export default function ProjectDevelopmentView({ const currentApprovalLabel = approvalOptions.find((option) => option.id === approvalMode)?.label ?? '严格审批'; + const FocusIcon = focusedResource + ? categoryIcons[focusedResource.category] + : FileText; + const focusedPreviewMediaType = + focusedResource && + mediaPreview.status === 'loaded' && + mediaPreview.resourceId === focusedResource.id + ? mediaPreview.preview.mediaType + : focusedResource && + imagePreview.status === 'loaded' && + imagePreview.resourceId === focusedResource.id + ? imagePreview.preview.mediaType + : focusedResource && + textPreview.status === 'loaded' && + textPreview.resourceId === focusedResource.id + ? textPreview.preview.mediaType + : focusedResource?.mediaType; useEffect(() => { if (embeddedPreviewUrl) { + suppressResourceFocusRestoreRef.current = true; + restoreResourceListScrollRef.current = false; + resourceFocusTriggerIdRef.current = null; + setFocusedResourceId(null); setMode('run'); } }, [embeddedPreviewUrl]); - useEffect(() => { + useLayoutEffect(() => { + if (resourceFocusProjectPathRef.current === projectPath) { + return; + } + resourceFocusProjectPathRef.current = projectPath; + suppressResourceFocusRestoreRef.current = true; + previousFocusedResourceIdRef.current = null; + resourceFocusTriggerIdRef.current = null; setSelectedResourceId(null); - resourceCardDragRef.current = null; - setDraggedResourceId(null); - setResourceDragPreview(null); + setFocusedResourceId(null); + resourceListScrollRef.current = { left: 0, top: 0 }; + restoreResourceListScrollRef.current = false; }, [projectPath]); useEffect(() => { - if (!selectedResourceId) { + if (!focusedResourceId) { return undefined; } function closeOnEscape(event: KeyboardEvent) { if (event.key === 'Escape') { - setSelectedResourceId(null); + restoreResourceListScrollRef.current = true; + setFocusedResourceId(null); } } window.addEventListener('keydown', closeOnEscape); return () => window.removeEventListener('keydown', closeOnEscape); - }, [selectedResourceId]); + }, [focusedResourceId]); useEffect(() => { - if (!selectedResource || !selectedResourceIsImage) { + if ( + !focusedResourceId || + !focusedResourcePath || + !focusedResourceIsImage + ) { setImagePreview({ status: 'idle', resourceId: null }); return undefined; } @@ -649,23 +850,27 @@ export default function ProjectDevelopmentView({ if (!invoke) { setImagePreview({ status: 'failed', - resourceId: selectedResource.id, + resourceId: focusedResourceId, error: '图片预览需要在客户端内打开', }); return undefined; } let cancelled = false; - setImagePreview({ status: 'loading', resourceId: selectedResource.id }); + setImagePreview((current) => + current.resourceId === focusedResourceId + ? current + : { status: 'loading', resourceId: focusedResourceId }, + ); void invoke('read_local_project_image_preview', { projectPath, - relativePath: selectedResource.path, + relativePath: focusedResourcePath, }) .then((preview) => { if (!cancelled) { setImagePreview({ status: 'loaded', - resourceId: selectedResource.id, + resourceId: focusedResourceId, preview, }); } @@ -674,7 +879,7 @@ export default function ProjectDevelopmentView({ if (!cancelled) { setImagePreview({ status: 'failed', - resourceId: selectedResource.id, + resourceId: focusedResourceId, error: imagePreviewErrorMessage(error), }); } @@ -682,212 +887,244 @@ export default function ProjectDevelopmentView({ return () => { cancelled = true; }; - }, [projectPath, selectedResource, selectedResourceIsImage]); - - const clampResourceDialogPosition = useCallback((x: number, y: number) => { - const dialog = resourceDialogRef.current; - const workbench = workbenchRef.current; - const dock = dockRef.current; - if (!dialog) { - return { x, y }; - } - - const dialogRect = dialog.getBoundingClientRect(); - const workbenchRect = workbench?.getBoundingClientRect(); - const dockRect = dock?.getBoundingClientRect(); - const viewportWidth = document.documentElement.clientWidth; - const viewportHeight = document.documentElement.clientHeight; - const minX = Math.max(12, (workbenchRect?.left ?? 0) + 12); - const maxRight = Math.min( - viewportWidth - 12, - (workbenchRect?.right ?? viewportWidth) - 12, - ); - const minY = Math.max(12, (workbenchRect?.top ?? 0) + 12); - const maxBottom = Math.min( - viewportHeight - 12, - dockRect ? dockRect.top - 12 : viewportHeight - 12, - ); - const maxX = Math.max(minX, maxRight - dialogRect.width); - const maxY = Math.max(minY, maxBottom - dialogRect.height); - - return { - x: Math.min(Math.max(x, minX), maxX), - y: Math.min(Math.max(y, minY), maxY), - }; - }, []); - - useLayoutEffect(() => { - if (!selectedResource) { - setResourceDialogPosition(null); - return; - } - const dialog = resourceDialogRef.current; - const stage = stageRef.current; - if (!dialog || !stage) { - return; - } - const dialogRect = dialog.getBoundingClientRect(); - const stageRect = stage.getBoundingClientRect(); - setResourceDialogPosition( - clampResourceDialogPosition( - stageRect.left + (stageRect.width - dialogRect.width) / 2, - stageRect.top + (stageRect.height - dialogRect.height) / 2, - ), - ); - dialog.focus({ preventScroll: true }); - }, [clampResourceDialogPosition, selectedResource]); + }, [ + focusedResourceId, + focusedResourceIsImage, + focusedResourcePath, + projectPath, + ]); useEffect(() => { - if (!selectedResource) { + if ( + !focusedResourceId || + !focusedResourcePath || + focusedResourceCategory !== 'document' + ) { + setTextPreview({ status: 'idle', resourceId: null }); return undefined; } - function clampOnResize() { - setResourceDialogPosition((current) => - current ? clampResourceDialogPosition(current.x, current.y) : current, - ); + if (focusedResourceContent !== undefined) { + setTextPreview({ + status: 'loaded', + resourceId: focusedResourceId, + preview: { + path: focusedResourcePath, + mediaType: focusedResourceMediaType ?? 'text/plain', + byteLen: new TextEncoder().encode(focusedResourceContent).byteLength, + content: focusedResourceContent, + }, + }); + return undefined; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setTextPreview({ + status: 'failed', + resourceId: focusedResourceId, + error: '文档预览需要在客户端内打开', + }); + return undefined; } - window.addEventListener('resize', clampOnResize); - return () => window.removeEventListener('resize', clampOnResize); - }, [clampResourceDialogPosition, selectedResource]); - function handleResourceCardPointerDown( - event: ReactPointerEvent, - resource: ProjectResource, - ) { - if (event.button !== 0) { - return; - } - const position = resourcePositionById.get(resource.id); - if (!position || position.section !== resource.category) { - return; - } - resourceCardDragRef.current = { - pointerId: event.pointerId, - resourceId: resource.id, - section: resource.category, - startClientX: event.clientX, - startClientY: event.clientY, - startX: position.x, - startY: position.y, - moved: false, - }; - event.currentTarget.setPointerCapture?.(event.pointerId); - } - - function handleResourceCardPointerMove( - event: ReactPointerEvent, - ) { - const drag = resourceCardDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - const deltaX = event.clientX - drag.startClientX; - const deltaY = event.clientY - drag.startClientY; - if ( - !drag.moved && - Math.hypot(deltaX, deltaY) < RESOURCE_CANVAS_DRAG_THRESHOLD - ) { - return; - } - drag.moved = true; - setDraggedResourceId(drag.resourceId); - setResourceDragPreview({ - resourceId: drag.resourceId, - x: Math.max(0, drag.startX + deltaX), - y: Math.max(0, drag.startY + deltaY), - }); - event.preventDefault(); - } - - function handleResourceCardPointerEnd( - event: ReactPointerEvent, - cancelled: boolean, - ) { - const drag = resourceCardDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - resourceCardDragRef.current = null; - if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { - event.currentTarget.releasePointerCapture?.(event.pointerId); - } - if (drag.moved && !cancelled) { - suppressResourceClickRef.current = drag.resourceId; - commitResourcePosition( - drag.resourceId, - drag.section, - Math.max(0, drag.startX + event.clientX - drag.startClientX), - Math.max(0, drag.startY + event.clientY - drag.startClientY), - ); - } - setDraggedResourceId(null); - setResourceDragPreview(null); - } - - function handleResourceDialogPointerDown( - event: ReactPointerEvent, - ) { - if (event.button !== 0 || (event.target as HTMLElement).closest('button')) { - return; - } - const dialogRect = resourceDialogRef.current?.getBoundingClientRect(); - if (!dialogRect) { - return; - } - resourceDialogDragRef.current = { - pointerId: event.pointerId, - offsetX: event.clientX - dialogRect.left, - offsetY: event.clientY - dialogRect.top, - }; - event.currentTarget.setPointerCapture(event.pointerId); - event.preventDefault(); - } - - function handleResourceDialogPointerMove( - event: ReactPointerEvent, - ) { - const drag = resourceDialogDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - setResourceDialogPosition( - clampResourceDialogPosition( - event.clientX - drag.offsetX, - event.clientY - drag.offsetY, - ), + let cancelled = false; + setTextPreview((current) => + current.resourceId === focusedResourceId + ? current + : { status: 'loading', resourceId: focusedResourceId }, ); - } + void invoke('read_local_project_text_preview', { + projectPath, + relativePath: focusedResourcePath, + }) + .then((preview) => { + if (!cancelled) { + setTextPreview({ + status: 'loaded', + resourceId: focusedResourceId, + preview, + }); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setTextPreview({ + status: 'failed', + resourceId: focusedResourceId, + error: mediaPreviewErrorMessage(error), + }); + } + }); + return () => { + cancelled = true; + }; + }, [ + focusedResourceCategory, + focusedResourceContent, + focusedResourceId, + focusedResourceMediaType, + focusedResourcePath, + projectPath, + ]); - function handleResourceDialogPointerEnd( - event: ReactPointerEvent, - ) { - if (resourceDialogDragRef.current?.pointerId !== event.pointerId) { + useEffect(() => { + if ( + !focusedResourceId || + !focusedResourcePath || + !focusedResourceCategory || + (!focusedResourceIsExtendedArtMedia && !focusedResourceIsAudio) + ) { + setMediaPreview({ status: 'idle', resourceId: null }); + setMediaDuration(null); + return undefined; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setMediaPreview({ + status: 'failed', + resourceId: focusedResourceId, + error: '媒体预览需要在客户端内打开', + }); + return undefined; + } + + let cancelled = false; + setMediaDuration(null); + setMediaPreview((current) => + current.resourceId === focusedResourceId + ? current + : { status: 'loading', resourceId: focusedResourceId }, + ); + void invoke('read_local_project_media_preview', { + projectPath, + relativePath: focusedResourcePath, + category: focusedResourceCategory, + }) + .then((preview) => { + if (!cancelled) { + setMediaPreview({ + status: 'loaded', + resourceId: focusedResourceId, + preview, + }); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setMediaPreview({ + status: 'failed', + resourceId: focusedResourceId, + error: mediaPreviewErrorMessage(error), + }); + } + }); + return () => { + cancelled = true; + }; + }, [ + focusedResourceCategory, + focusedResourceId, + focusedResourceIsAudio, + focusedResourceIsExtendedArtMedia, + focusedResourcePath, + projectPath, + ]); + + useLayoutEffect(() => { + if (suppressResourceFocusRestoreRef.current) { + suppressResourceFocusRestoreRef.current = false; + previousFocusedResourceIdRef.current = focusedResourceId; return; } - resourceDialogDragRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); + if (focusedResourceId && !focusedResource) { + previousFocusedResourceIdRef.current = null; + resourceFocusTriggerIdRef.current = null; + restoreResourceListScrollRef.current = false; + setSelectedResourceId((current) => + current === focusedResourceId ? null : current, + ); + setFocusedResourceId(null); + resourceSearchRef.current?.focus({ preventScroll: true }); + return; } + if (focusedResourceId && focusedResource) { + if (previousFocusedResourceIdRef.current !== focusedResourceId) { + resourceFocusRef.current?.focus({ preventScroll: true }); + } + previousFocusedResourceIdRef.current = focusedResourceId; + return; + } + previousFocusedResourceIdRef.current = null; + if (!restoreResourceListScrollRef.current) { + return; + } + const canvas = resourceCanvasRef.current; + if (canvas) { + canvas.scrollLeft = resourceListScrollRef.current.left; + canvas.scrollTop = resourceListScrollRef.current.top; + const triggerResourceId = resourceFocusTriggerIdRef.current; + const triggerCard = triggerResourceId + ? Array.from( + canvas.querySelectorAll('[data-resource-id]'), + ).find((card) => card.dataset.resourceId === triggerResourceId) + : null; + if (triggerCard) { + triggerCard.focus({ preventScroll: true }); + } else { + resourceSearchRef.current?.focus({ preventScroll: true }); + } + } else { + resourceSearchRef.current?.focus({ preventScroll: true }); + } + resourceFocusTriggerIdRef.current = null; + restoreResourceListScrollRef.current = false; + }, [focusedResource, focusedResourceId]); + + const handleResourceSelect = useCallback((resourceId: string) => { + const canvas = resourceCanvasRef.current; + if (canvas) { + resourceListScrollRef.current = { + left: canvas.scrollLeft, + top: canvas.scrollTop, + }; + } + suppressResourceFocusRestoreRef.current = false; + resourceFocusTriggerIdRef.current = resourceId; + setSelectedResourceId(resourceId); + setFocusedResourceId(resourceId); + }, []); + + function closeResourceFocus() { + restoreResourceListScrollRef.current = true; + setFocusedResourceId(null); } function showRunView() { if (!runAvailable) { return; } + suppressResourceFocusRestoreRef.current = true; + restoreResourceListScrollRef.current = false; + resourceFocusTriggerIdRef.current = null; + setFocusedResourceId(null); setMode('run'); } return (
@@ -915,7 +1152,7 @@ export default function ProjectDevelopmentView({
- {mode === 'resources' ? ( + {mode === 'resources' && !focusedResource ? ( <>
- {!runAvailable ? ( + {!runAvailable && !focusedResource ? (

) : null} - {mode === 'resources' ? ( + {mode === 'resources' && focusedResource ? ( +

+
+ + + + + {categoryLabels[focusedResource.category]} + + {focusedResource.label} + + + + +
+
+ {focusedResourceIsImage ? ( +
+ {imagePreview.status === 'loaded' && + imagePreview.resourceId === focusedResource.id ? ( + {`${focusedResource.label} + setImagePreview({ + status: 'failed', + resourceId: focusedResource.id, + error: '图片内容无法解码,请重新生成或替换该资源', + }) + } + /> + ) : imagePreview.status === 'failed' && + imagePreview.resourceId === focusedResource.id ? ( +

{imagePreview.error}

+ ) : ( +

正在载入图片…

+ )} +
+ ) : null} + {focusedResourceIsExtendedArtMedia ? ( +
+ {mediaPreview.status === 'loaded' && + mediaPreview.resourceId === focusedResource.id ? ( + mediaPreview.preview.mediaType.startsWith('video/') ? ( +
+ ) : null} + {focusedResourceIsAudio ? ( +
+ {mediaPreview.status === 'loaded' && + mediaPreview.resourceId === focusedResource.id ? ( +
+ ) : null} + {focusedResource.category === 'document' ? ( +
+ {textPreview.status === 'loaded' && + textPreview.resourceId === focusedResource.id ? ( + textPreview.preview.content.trim() ? ( + + ) : ( +

文档为空

+ ) + ) : textPreview.status === 'failed' && + textPreview.resourceId === focusedResource.id ? ( +

{textPreview.error}

+ ) : ( +

正在载入文档…

+ )} +
+ ) : null} +
+
+
资源路径
+
{focusedResource.path}
+
+
+
资源类型
+
{focusedPreviewMediaType}
+
+
+
资源来源
+
{focusedResource.sourceLabel}
+
+ {focusedResource.taskTitle ? ( +
+
来源任务
+
{focusedResource.taskTitle}
+
+ ) : null} + {focusedResourceIsAudio ? ( +
+
音频时长
+
{formatMediaDuration(mediaDuration)}
+
+ ) : null} + {focusedResource.version ? ( + <> +
+
版本 ID
+
{focusedResource.version.versionId}
+
+
+
项目修订
+
{focusedResource.version.projectRevision}
+
+
+
父版本
+
+ {focusedResource.version.parentVersionId ?? + '首个版本'} +
+
+
+
直接子版本
+
+ {focusedResource.version.childVersionIds.length > 0 + ? focusedResource.version.childVersionIds.join('、') + : '暂无'} +
+
+
+
创建原因
+
+ { + { + initial: '初始版本', + 'resource-replacement': '资源替换', + 'agent-revision': 'Agent 修订', + }[focusedResource.version.createdReason] + } +
+
+
+
创建时间
+
+ {formatVersionCreatedAt( + focusedResource.version.createdAt, + )} +
+
+
+
资源绑定
+
+ {focusedResource.version.resourceBindings.length > 0 + ? focusedResource.version.resourceBindings + .map( + (binding) => + `${binding.slotId} → ${binding.resourceId}`, + ) + .join(';') + : '暂无'} +
+
+ + ) : null} +
+
+
+ ) : mode === 'resources' ? (
) : ( @@ -1207,11 +1697,7 @@ export default function ProjectDevelopmentView({
-
+
{agentSummaries.map((agent) => (
- {selectedResource ? ( -
- -
- ) : null} - {approvalDialogOpen ? (
; + downstreamReferenceResourceIds: ReadonlySet; + referenceEdgeIds: ReadonlySet; + taskFlowIds: ReadonlySet; +}; + +export type ProjectResourceGraph = { + resourceIds: ReadonlySet; + referenceEdges: ProjectResourceReferenceEdge[]; + referenceEdgeById: ReadonlyMap; + taskFlows: ProjectResourceTaskFlow[]; + taskFlowById: ReadonlyMap; + connectionIndex: ReadonlyMap; + producerTaskIdByResourceId: ReadonlyMap; + dependencyDepthByResourceId: ReadonlyMap; + unresolvedReferenceResourceIds: string[]; + cyclicResourceIds: ReadonlySet; + cyclicTaskIds: ReadonlySet; + producerMappingTruncated: boolean; +}; + +export type ProjectResourceGraphNeighbors = { + upstreamResourceIds: ReadonlySet; + downstreamResourceIds: ReadonlySet; + connectedEdgeIds: ReadonlySet; +}; + +const emptyStringSet: ReadonlySet = new Set(); +const emptyStringMap: ReadonlyMap = new Map(); +const emptyNumberMap: ReadonlyMap = new Map(); +const emptyConnectionMap: ReadonlyMap = + new Map(); +const emptyTaskFlowMap: ReadonlyMap = new Map< + string, + ProjectResourceTaskFlow +>(); +const emptyReferenceEdgeMap: ReadonlyMap = + new Map(); + +export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbors = + { + upstreamResourceIds: emptyStringSet, + downstreamResourceIds: emptyStringSet, + connectedEdgeIds: emptyStringSet, + }; + +export const EMPTY_PROJECT_RESOURCE_GRAPH: ProjectResourceGraph = { + resourceIds: emptyStringSet, + referenceEdges: [], + referenceEdgeById: emptyReferenceEdgeMap, + taskFlows: [], + taskFlowById: emptyTaskFlowMap, + connectionIndex: emptyConnectionMap, + producerTaskIdByResourceId: emptyStringMap, + dependencyDepthByResourceId: emptyNumberMap, + unresolvedReferenceResourceIds: [], + cyclicResourceIds: emptyStringSet, + cyclicTaskIds: emptyStringSet, + producerMappingTruncated: false, +}; + +function uniqueSorted(values: Iterable) { + return Array.from(new Set(values)).sort((left, right) => + left < right ? -1 : left > right ? 1 : 0, + ); +} + +export function normalizeProjectResourceGraph( + readModel: ProjectResourceGraphReadModel, +): ProjectResourceGraph { + const producerMappingTruncated = Boolean(readModel.producerMappingTruncated); + const resourceIds = new Set(uniqueSorted(readModel.resourceIds)); + const referenceEdges = readModel.referenceEdges + .filter( + (edge) => + edge.kind === 'asset-reference' && + resourceIds.has(edge.sourceResourceId) && + resourceIds.has(edge.targetResourceId), + ) + .sort((left, right) => left.id.localeCompare(right.id)); + const referenceEdgeIds = new Set(referenceEdges.map((edge) => edge.id)); + const taskFlows = (producerMappingTruncated ? [] : readModel.taskFlows) + .flatMap((flow) => { + if (flow.kind !== 'task-flow') { + return []; + } + const sourceResourceIds = uniqueSorted( + flow.sourceResourceIds.filter((resourceId) => + resourceIds.has(resourceId), + ), + ); + const targetResourceIds = uniqueSorted( + flow.targetResourceIds.filter((resourceId) => + resourceIds.has(resourceId), + ), + ); + return sourceResourceIds.length > 0 && targetResourceIds.length > 0 + ? [{ ...flow, sourceResourceIds, targetResourceIds }] + : []; + }) + .sort((left, right) => left.id.localeCompare(right.id)); + const taskFlowIds = new Set(taskFlows.map((flow) => flow.id)); + const connectionIndex = new Map(); + for (const index of readModel.connectionIndex) { + if (!resourceIds.has(index.resourceId)) { + continue; + } + connectionIndex.set(index.resourceId, { + upstreamReferenceResourceIds: new Set( + index.upstreamReferenceResourceIds.filter((resourceId) => + resourceIds.has(resourceId), + ), + ), + downstreamReferenceResourceIds: new Set( + index.downstreamReferenceResourceIds.filter((resourceId) => + resourceIds.has(resourceId), + ), + ), + referenceEdgeIds: new Set( + index.referenceEdgeIds.filter((edgeId) => referenceEdgeIds.has(edgeId)), + ), + taskFlowIds: new Set( + index.taskFlowIds.filter((flowId) => taskFlowIds.has(flowId)), + ), + }); + } + const producerTaskIdByResourceId = new Map(); + const dependencyDepthByResourceId = new Map(); + for (const assignment of producerMappingTruncated + ? [] + : readModel.producerAssignments) { + if (!resourceIds.has(assignment.resourceId) || !assignment.taskId) { + continue; + } + producerTaskIdByResourceId.set(assignment.resourceId, assignment.taskId); + } + for (const depth of readModel.dependencyDepths) { + if ( + resourceIds.has(depth.resourceId) && + Number.isSafeInteger(depth.dependencyDepth) && + depth.dependencyDepth >= 0 + ) { + dependencyDepthByResourceId.set( + depth.resourceId, + Math.max( + dependencyDepthByResourceId.get(depth.resourceId) ?? 0, + depth.dependencyDepth, + ), + ); + } + } + + return { + resourceIds, + referenceEdges, + referenceEdgeById: new Map(referenceEdges.map((edge) => [edge.id, edge])), + taskFlows, + taskFlowById: new Map(taskFlows.map((flow) => [flow.id, flow])), + connectionIndex, + producerTaskIdByResourceId, + dependencyDepthByResourceId, + unresolvedReferenceResourceIds: uniqueSorted( + readModel.unresolvedReferenceResourceIds, + ), + cyclicResourceIds: new Set( + readModel.cyclicResourceIds.filter((resourceId) => + resourceIds.has(resourceId), + ), + ), + cyclicTaskIds: new Set( + producerMappingTruncated ? [] : readModel.cyclicTaskIds, + ), + producerMappingTruncated, + }; +} + +export function projectResourceGraphNeighbors( + graph: ProjectResourceGraph, + resourceId: string | null, +): ProjectResourceGraphNeighbors { + if (!resourceId || !graph.resourceIds.has(resourceId)) { + return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS; + } + const index = graph.connectionIndex.get(resourceId); + if (!index) { + return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS; + } + const upstreamResourceIds = new Set(index.upstreamReferenceResourceIds); + const downstreamResourceIds = new Set(index.downstreamReferenceResourceIds); + const connectedEdgeIds = new Set(index.referenceEdgeIds); + for (const flowId of index.taskFlowIds) { + const flow = graph.taskFlowById.get(flowId); + if (!flow) { + continue; + } + if (flow.targetResourceIds.includes(resourceId)) { + flow.sourceResourceIds.forEach((id) => upstreamResourceIds.add(id)); + connectedEdgeIds.add(flow.id); + } + if (flow.sourceResourceIds.includes(resourceId)) { + flow.targetResourceIds.forEach((id) => downstreamResourceIds.add(id)); + connectedEdgeIds.add(flow.id); + } + } + + upstreamResourceIds.delete(resourceId); + downstreamResourceIds.delete(resourceId); + return { upstreamResourceIds, downstreamResourceIds, connectedEdgeIds }; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts new file mode 100644 index 000000000..6cc98c8e5 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts @@ -0,0 +1,278 @@ +import type { + GameCreationAppManifest, + GameIterationVersion, + ProjectResourceCanvasSection, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; + +export type ProjectResourceCategory = ProjectResourceCanvasSection; + +export type ProjectAttachmentResult = { + fileName: string; + mediaType: string; + localPath?: string; + status: 'imported' | 'failed'; + error?: string; +}; + +export type ProjectAgentResultSummary = { + agentId: string; + runId: string; + label: string; + title: string; + content: string; + updatedAt: number; +}; + +export type ProjectVersionResourceSummary = GameIterationVersion & { + label: string; + childVersionIds: string[]; +}; + +export type ProjectResource = { + id: string; + category: ProjectResourceCategory; + subtype: string; + label: string; + path: string; + mediaType: string; + sourceLabel: string; + taskTitle: string | null; + manifestAssetId: string | null; + producerTaskId: string | null; + externalResourceId: string | null; + referenceResourceIds: string[]; + dependencies: string[]; + dependencyDepth: number; + content?: string; + version?: ProjectVersionResourceSummary; +}; + +const documentExtension = /\.(md|markdown|mdx|txt|json|ya?ml|toml)$/iu; +const artExtension = /\.(png|jpe?g|webp|gif|svg|avif|bmp|mp4|webm|mov)$/iu; +const audioExtension = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/iu; +const artKind = + /(?:^|[-_])(art|animation|character|icon|image|scene|sprite|spritesheet|ui|video)(?:$|[-_])/iu; +const audioKind = /(?:^|[-_])(audio|bgm|music|sfx|sound|voice)(?:$|[-_])/iu; + +function fileName(path: string) { + return path.split(/[\\/]/u).filter(Boolean).pop() || path; +} + +export function classifyProjectedResource(input: { + path: string; + mediaType: string; + kind?: string; +}): Exclude | null { + const normalizedPath = input.path.trim().toLowerCase(); + const normalizedMediaType = input.mediaType.trim().toLowerCase(); + const normalizedKind = input.kind?.trim().toLowerCase() ?? ''; + + if ( + normalizedMediaType.startsWith('audio/') || + audioExtension.test(normalizedPath) || + audioKind.test(normalizedKind) + ) { + return 'audio'; + } + if ( + normalizedMediaType.startsWith('image/') || + normalizedMediaType.startsWith('video/') || + artExtension.test(normalizedPath) || + artKind.test(normalizedKind) + ) { + return 'art'; + } + if ( + normalizedMediaType.includes('json') || + normalizedMediaType.includes('yaml') || + normalizedMediaType.startsWith('text/') || + documentExtension.test(normalizedPath) + ) { + return 'document'; + } + return null; +} + +function resourcePriority(resource: ProjectResource) { + if (resource.manifestAssetId) { + return 4; + } + if (resource.id.startsWith('attachment:')) { + return 3; + } + if (resource.id.startsWith('task:')) { + return 2; + } + return 1; +} + +export function projectResourcesFromReadModels( + manifest: GameCreationAppManifest, + attachments: ProjectAttachmentResult[], + agentResults: ProjectAgentResultSummary[], +) { + const taskById = new Map(manifest.tasks.map((task) => [task.id, task])); + const resources: ProjectResource[] = []; + + for (const task of manifest.tasks) { + if (task.status !== 'completed') { + continue; + } + for (const path of task.artifacts) { + const category = classifyProjectedResource({ path, mediaType: '' }); + if (!category || category === 'audio') { + continue; + } + resources.push({ + id: `task:${task.id}:${path}`, + category, + subtype: 'task-artifact', + label: fileName(path), + path, + mediaType: category === 'document' ? '项目文档' : '美术产物', + sourceLabel: '任务产物', + taskTitle: task.title, + manifestAssetId: null, + producerTaskId: task.id, + externalResourceId: null, + referenceResourceIds: [], + dependencies: task.dependencies, + dependencyDepth: 0, + }); + } + } + + for (const asset of manifest.assets) { + const category = classifyProjectedResource({ + path: asset.localPath, + mediaType: asset.mediaType, + kind: asset.kind, + }); + if (!category) { + continue; + } + const isPendingUiPrototype = + asset.kind === 'ui-prototype' && + taskById.get('design-foundation')?.status !== 'completed'; + resources.push({ + id: `asset:${asset.id}`, + category, + subtype: asset.kind, + label: `${fileName(asset.localPath)}${ + isPendingUiPrototype ? '(待视觉验收)' : '' + }`, + path: asset.localPath, + mediaType: asset.mediaType, + sourceLabel: + isPendingUiPrototype && asset.source.kind === 'canvas' + ? '画板 · 候选界面图' + : asset.source.kind === 'canvas' + ? '画板' + : asset.source.kind === 'generated' + ? 'Agent 生成' + : '用户上传', + taskTitle: null, + manifestAssetId: asset.id, + producerTaskId: null, + externalResourceId: asset.source.resourceId ?? null, + referenceResourceIds: asset.source.referenceResourceIds ?? [], + dependencies: [], + dependencyDepth: 0, + }); + } + + for (const attachment of attachments) { + if (attachment.status !== 'imported' || !attachment.localPath) { + continue; + } + const category = classifyProjectedResource({ + path: attachment.localPath, + mediaType: attachment.mediaType, + }); + if (!category) { + continue; + } + resources.push({ + id: `attachment:${attachment.localPath}`, + category, + subtype: 'attachment', + label: attachment.fileName, + path: attachment.localPath, + mediaType: attachment.mediaType || '未知媒体类型', + sourceLabel: '用户上传', + taskTitle: null, + manifestAssetId: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + }); + } + + for (const result of agentResults) { + resources.push({ + id: `agent-result:${result.agentId}:${result.runId}`, + category: 'document', + subtype: 'agent-result', + label: result.title, + path: `专业 Agent 文本回执 · ${result.label}`, + mediaType: 'Agent 历史文本回执', + sourceLabel: `历史成果 · ${result.label}`, + taskTitle: null, + manifestAssetId: null, + producerTaskId: taskById.has(result.agentId) ? result.agentId : null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + content: result.content, + }); + } + + const childVersionIdsByParent = new Map(); + for (const version of manifest.versions ?? []) { + if (!version.parentVersionId) { + continue; + } + const children = childVersionIdsByParent.get(version.parentVersionId) ?? []; + children.push(version.versionId); + childVersionIdsByParent.set(version.parentVersionId, children); + } + for (const [index, manifestVersion] of (manifest.versions ?? []).entries()) { + const version: ProjectVersionResourceSummary = { + ...manifestVersion, + label: `版本 ${index + 1}`, + childVersionIds: + childVersionIdsByParent.get(manifestVersion.versionId) ?? [], + }; + resources.push({ + id: `version:${version.versionId}`, + category: 'version', + subtype: 'project-version', + label: version.label, + path: `项目版本 · ${version.versionId}`, + mediaType: '正式项目版本', + sourceLabel: version.parentVersionId + ? `项目修订 ${version.projectRevision} · 父版本 ${version.parentVersionId}` + : `项目修订 ${version.projectRevision} · 初始版本`, + taskTitle: null, + manifestAssetId: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + version, + }); + } + + const uniqueByPath = new Map(); + for (const resource of resources) { + const existing = uniqueByPath.get(resource.path); + if (!existing || resourcePriority(resource) > resourcePriority(existing)) { + uniqueByPath.set(resource.path, resource); + } + } + return Array.from(uniqueByPath.values()); +} 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 745ca2c37..66a6a6802 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 @@ -20,7 +20,7 @@ type LayoutNotice = | '布局读取失败,已使用当前会话布局' | '布局保存失败,已保留当前会话布局' | '布局保存失败,已恢复上次布局' - | '布局已在其他窗口更新,请重新拖动'; + | '布局已在其他窗口更新'; type LayoutScope = { key: string; @@ -81,16 +81,58 @@ function layoutMatchesScope( return layout.projectId === scope.projectId && layout.mode === scope.mode; } +function positionsEqual( + left: ProjectResourceCanvasLayout['positions'], + right: ProjectResourceCanvasLayout['positions'], +) { + return ( + left.length === right.length && + left.every((position, index) => { + const other = right[index]; + return ( + other?.resourceId === position.resourceId && + other.section === position.section && + other.x === position.x && + other.y === position.y && + other.manuallyPlaced === position.manuallyPlaced + ); + }) + ); +} + +function reconcileLayout( + source: ProjectResourceCanvasLayout, + resources: ResourceCanvasItem[], + rederiveAutomaticPositions: boolean, +) { + if (!rederiveAutomaticPositions) { + return reconcileResourceCanvasLayout(source, resources); + } + const manualSource = { + ...source, + positions: source.positions.filter((position) => position.manuallyPlaced), + }; + const reconciled = reconcileResourceCanvasLayout(manualSource, resources); + return { + layout: reconciled.layout, + changed: !positionsEqual(source.positions, reconciled.layout.positions), + }; +} + export function useProjectResourceCanvasLayout({ projectPath, projectId, mode, resources, + initializationReady = true, + rederiveAutomaticPositions = false, }: { projectPath: string; projectId: string; mode: ProjectResourceCanvasLayoutMode; resources: ResourceCanvasItem[]; + initializationReady?: boolean; + rederiveAutomaticPositions?: boolean; }) { const scopeKey = createScopeKey(projectPath, projectId, mode); const resourceSignature = useMemo( @@ -98,13 +140,18 @@ export function useProjectResourceCanvasLayout({ [resources], ); const fallback = useMemo( - () => - reconcileResourceCanvasLayout( - createEmptyResourceCanvasLayout(projectId, mode), - resources, - ).layout, - [mode, projectId, resources], - ); + () => { + const empty = createEmptyResourceCanvasLayout(projectId, mode); + return initializationReady + ? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout + : empty; + }, [ + initializationReady, + mode, + projectId, + rederiveAutomaticPositions, + resources, + ]); const [layout, setLayout] = useState(fallback); const [notice, setNotice] = useState(''); const [saving, setSaving] = useState(false); @@ -151,9 +198,10 @@ export function useProjectResourceCanvasLayout({ if (scope.epoch !== scopeEpoch) { return; } - let next = reconcileResourceCanvasLayout( + let next = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, + rederiveAutomaticPositions, ).layout; for (const intent of writeQueueRef.current) { if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') { @@ -168,7 +216,7 @@ export function useProjectResourceCanvasLayout({ } applyLayout(next); }, - [applyLayout], + [applyLayout, rederiveAutomaticPositions], ); enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => { @@ -229,9 +277,10 @@ export function useProjectResourceCanvasLayout({ return; } - const reconciled = reconcileResourceCanvasLayout( + const reconciled = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, + rederiveAutomaticPositions, ); if (intent.kind === 'resources' && !reconciled.changed) { removeWriteIntent(intent); @@ -330,7 +379,11 @@ export function useProjectResourceCanvasLayout({ setNotice('布局已保存'); } if ( - reconcileResourceCanvasLayout(result.layout, resourcesRef.current) + reconcileLayout( + result.layout, + resourcesRef.current, + rederiveAutomaticPositions, + ) .changed ) { enqueueResourceSyncRef.current(currentScope.epoch); @@ -346,9 +399,10 @@ export function useProjectResourceCanvasLayout({ queued.scopeEpoch !== currentScope.epoch || queued.kind !== 'manual', ); - const needsResourceSync = reconcileResourceCanvasLayout( + const needsResourceSync = reconcileLayout( result.layout, resourcesRef.current, + rederiveAutomaticPositions, ).changed; const nextRetry = intent.kind === 'resources' ? intent.conflictRetries + 1 : 0; @@ -364,7 +418,7 @@ export function useProjectResourceCanvasLayout({ enqueueResourceSyncRef.current(currentScope.epoch, nextRetry); } if (redragRequired || !willRetryResourceSync) { - setNotice('布局已在其他窗口更新,请重新拖动'); + setNotice('布局已在其他窗口更新'); } } rebuildOptimisticLayout(currentScope.epoch); @@ -435,9 +489,18 @@ export function useProjectResourceCanvasLayout({ writeQueueRef.current = []; activeWriteIntentRef.current = null; redragRequiredScopeEpochRef.current = null; - const initialFallback = reconcileResourceCanvasLayout( - createEmptyResourceCanvasLayout(projectId, mode), + const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode); + if (!initializationReady) { + persistedLayoutRef.current = emptyLayout; + applyLayout(emptyLayout); + setNotice(''); + setSaving(false); + return undefined; + } + const initialFallback = reconcileLayout( + emptyLayout, resourcesRef.current, + rederiveAutomaticPositions, ).layout; persistedLayoutRef.current = initialFallback; applyLayout(initialFallback); @@ -469,7 +532,11 @@ export function useProjectResourceCanvasLayout({ persistedLayoutRef.current = loaded; initializedScopeEpochRef.current = epoch; if ( - reconcileResourceCanvasLayout(loaded, resourcesRef.current).changed + reconcileLayout( + loaded, + resourcesRef.current, + rederiveAutomaticPositions, + ).changed ) { enqueueResourceSyncRef.current(epoch); } @@ -491,10 +558,12 @@ export function useProjectResourceCanvasLayout({ }; }, [ applyLayout, + initializationReady, mode, projectId, projectPath, rebuildOptimisticLayout, + rederiveAutomaticPositions, scopeKey, ]); @@ -502,32 +571,41 @@ export function useProjectResourceCanvasLayout({ const scope = scopeRef.current; if ( scope.key !== scopeKey || + !initializationReady || initializedScopeEpochRef.current !== scope.epoch ) { return; } - const reconciledCurrent = reconcileResourceCanvasLayout( + const reconciledCurrent = reconcileLayout( layoutRef.current, resourcesRef.current, + rederiveAutomaticPositions, ); applyLayout(reconciledCurrent.layout); if ( window.__TAURI__?.core?.invoke && - reconcileResourceCanvasLayout( + reconcileLayout( persistedLayoutRef.current, resourcesRef.current, + rederiveAutomaticPositions, ).changed ) { enqueueResourceSyncRef.current(scope.epoch); } - }, [applyLayout, resourceSignature, scopeKey]); + }, [ + applyLayout, + initializationReady, + rederiveAutomaticPositions, + resourceSignature, + scopeKey, + ]); useEffect(() => { if (!notice) { return undefined; } if ( - notice === '布局已在其他窗口更新,请重新拖动' && + notice === '布局已在其他窗口更新' && redragRequiredScopeEpochRef.current === scopeRef.current.epoch ) { return undefined; @@ -544,7 +622,11 @@ export function useProjectResourceCanvasLayout({ y: number, ) => { const scope = scopeRef.current; - if (scope.key !== scopeKey) { + if ( + scope.key !== scopeKey || + !initializationReady || + initializedScopeEpochRef.current !== scope.epoch + ) { return; } const queuedIntent = writeQueueRef.current.find( @@ -580,10 +662,11 @@ export function useProjectResourceCanvasLayout({ setSaving(true); pumpWritesRef.current(); }, - [applyLayout, scopeKey], + [applyLayout, initializationReady, scopeKey], ); const scopeMatches = + initializationReady && scopeRef.current.key === scopeKey && layout.projectId === projectId && layout.mode === mode; diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts new file mode 100644 index 000000000..8a0edb6ce --- /dev/null +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -0,0 +1,517 @@ +/** @vitest-environment jsdom */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { act, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { ProjectResourceCanvasSection } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + normalizeProjectResourceGraph, + type ProjectResourceGraph, + type ProjectResourceGraphReadModel, +} from '../src/view/project-development/resourceDependencyGraphModel'; +import { + ResourceDependencyOverlay, + type ResourceDependencyOverlayHandle, +} from '../src/view/project-development/ResourceDependencyOverlay'; + +function position( + resourceId: string, + x: number, + y: number, + section: ProjectResourceCanvasSection = 'art', +): ProjectResourceCanvasPosition { + return { + resourceId, + section, + x, + y, + manuallyPlaced: false, + }; +} + +function graphFixture() { + const referenceId = 'asset-reference:["source:one","target:one"]'; + const selfReferenceId = 'asset-reference:["unrelated","unrelated"]'; + const flowId = 'task-flow:["source-task","target-task"]'; + const resourceIds = [ + 'source:one', + 'source:two', + 'source:three', + 'target:one', + 'target:two', + 'target:three', + 'unrelated', + ]; + const readModel: ProjectResourceGraphReadModel = { + resourceIds, + referenceEdges: [ + { + id: referenceId, + kind: 'asset-reference', + sourceResourceId: 'source:one', + targetResourceId: 'target:one', + cyclic: false, + }, + { + id: selfReferenceId, + kind: 'asset-reference', + sourceResourceId: 'unrelated', + targetResourceId: 'unrelated', + cyclic: true, + }, + ], + taskFlows: [ + { + id: flowId, + kind: 'task-flow', + sourceTaskId: 'source-task', + targetTaskId: 'target-task', + sourceResourceIds: ['source:one', 'source:two', 'source:three'], + targetResourceIds: ['target:one', 'target:two', 'target:three'], + cyclic: false, + }, + ], + connectionIndex: resourceIds.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: + resourceId === 'target:one' + ? ['source:one'] + : resourceId === 'unrelated' + ? ['unrelated'] + : [], + downstreamReferenceResourceIds: + resourceId === 'source:one' + ? ['target:one'] + : resourceId === 'unrelated' + ? ['unrelated'] + : [], + referenceEdgeIds: + resourceId === 'source:one' || resourceId === 'target:one' + ? [referenceId] + : resourceId === 'unrelated' + ? [selfReferenceId] + : [], + taskFlowIds: resourceId === 'unrelated' ? [] : [flowId], + })), + producerAssignments: [], + dependencyDepths: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: ['unrelated'], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + return normalizeProjectResourceGraph(readModel); +} + +function OverlayHarness({ + graph, + positions, + visibleResourceIds, + overlayRef, +}: { + graph: ProjectResourceGraph; + positions: ProjectResourceCanvasPosition[]; + visibleResourceIds: ReadonlySet; + overlayRef?: React.Ref; +}) { + const sections = Array.from( + new Set(positions.map((position) => position.section)), + ); + return React.createElement( + 'div', + null, + ...sections.map((section) => + React.createElement('div', { + key: section, + 'data-resource-section-plane': section, + }), + ), + React.createElement(ResourceDependencyOverlay, { + ref: overlayRef, + graph, + positions, + visibleResourceIds, + }), + ); +} + +function overlayView( + graph: ProjectResourceGraph, + positions: ProjectResourceCanvasPosition[], + visibleResourceIds: ReadonlySet, + overlayRef?: React.Ref, +) { + return React.createElement(OverlayHarness, { + graph, + positions, + visibleResourceIds, + overlayRef, + }); +} + +describe('ResourceDependencyOverlay', () => { + it('renders exact references and one aggregated task trunk without cartesian paths', async () => { + const graph = graphFixture(); + const positions = Array.from(graph.resourceIds).map((resourceId, index) => + position(resourceId, (index % 3) * 220, Math.floor(index / 3) * 120), + ); + render(overlayView(graph, positions, new Set(graph.resourceIds))); + + const overlay = await screen.findByTestId('resource-dependency-overlay'); + await waitFor(() => + expect( + overlay.querySelectorAll('[data-edge-kind="asset-reference"]'), + ).toHaveLength(2), + ); + const taskFlow = overlay.querySelector('[data-edge-kind="task-flow"]'); + expect(taskFlow).not.toBeNull(); + const taskPaths = Array.from( + taskFlow?.querySelectorAll('path') ?? [], + ); + expect(taskPaths).toHaveLength(7); + expect( + taskPaths.every((path) => path.getAttribute('d')?.includes(' C ')), + ).toBe(true); + expect( + taskPaths.some((path) => path.getAttribute('d')?.includes(' L ')), + ).toBe(false); + expect(taskFlow?.querySelectorAll('path[marker-end]')).toHaveLength(3); + expect( + overlay + .querySelector('marker[id$="-task-flow-arrow"]') + ?.getAttribute('markerUnits'), + ).toBe('userSpaceOnUse'); + }); + + it('omits cross-section task endpoints and keeps same-section task flow groups', async () => { + const graph = graphFixture(); + const positions = [ + position('source:one', 0, 0, 'document'), + position('source:two', 0, 0, 'art'), + position('source:three', 0, 120, 'document'), + position('target:one', 240, 0, 'art'), + position('target:two', 240, 120, 'art'), + position('target:three', 240, 0, 'audio'), + ]; + render( + overlayView( + graph, + positions, + new Set(positions.map((position) => position.resourceId)), + ), + ); + + const overlay = await screen.findByTestId('resource-dependency-overlay'); + const taskFlow = await waitFor(() => { + const flows = overlay.querySelectorAll('[data-edge-kind="task-flow"]'); + expect(flows).toHaveLength(1); + return flows.item(0); + }); + expect(taskFlow.getAttribute('data-resource-section')).toBe('art'); + expect(taskFlow.querySelectorAll('path')).toHaveLength(4); + expect( + Array.from(taskFlow.querySelectorAll('[data-resource-id]')).map((node) => + node.getAttribute('data-resource-id'), + ), + ).toEqual(['source:two', 'target:one', 'target:two']); + expect( + overlay.querySelectorAll('[data-edge-kind="asset-reference"]'), + ).toHaveLength(1); + }); + + it('filters hidden endpoints and updates path geometry when positions change', async () => { + const graph = graphFixture(); + const positions = [ + position('source:one', 0, 0), + position('target:one', 240, 0), + ]; + const overlayRef = React.createRef(); + const view = render( + overlayView( + graph, + positions, + new Set(['source:one', 'target:one']), + overlayRef, + ), + ); + const overlay = await screen.findByTestId('resource-dependency-overlay'); + const firstPath = await waitFor(() => { + const path = overlay.querySelector( + '[data-edge-kind="asset-reference"]', + ); + expect(path).not.toBeNull(); + return path?.getAttribute('d'); + }); + + act(() => + overlayRef.current?.updateDragPreview({ + resourceId: 'source:one', + x: 80, + y: 40, + }), + ); + await waitFor(() => + expect( + overlay + .querySelector('[data-edge-kind="asset-reference"]') + ?.getAttribute('d'), + ).not.toBe(firstPath), + ); + + view.rerender(overlayView(graph, positions, new Set(['target:one']))); + await waitFor(() => + expect( + overlay.querySelector('[data-edge-kind="asset-reference"]'), + ).toBeNull(), + ); + }); + + it('keeps the section origin when updating a dragged path', async () => { + const getBoundingClientRect = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function () { + const isPlane = this.hasAttribute('data-resource-section-plane'); + return { + x: isPlane ? 160 : 20, + y: isPlane ? 90 : 10, + left: isPlane ? 160 : 20, + top: isPlane ? 90 : 10, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON: () => ({}), + }; + }); + try { + const graph = graphFixture(); + const positions = [ + position('source:one', 0, 0), + position('target:one', 240, 0), + ]; + const visible = new Set(['source:one', 'target:one']); + const overlayRef = React.createRef(); + render(overlayView(graph, positions, visible, overlayRef)); + const overlay = await screen.findByTestId('resource-dependency-overlay'); + const selector = '[data-edge-kind="asset-reference"]'; + await waitFor(() => + expect(overlay.querySelector(selector)?.getAttribute('d')).toContain( + 'M 320 126', + ), + ); + + act(() => + overlayRef.current?.updateDragPreview({ + resourceId: 'source:one', + x: 80, + y: 40, + }), + ); + await waitFor(() => + expect(overlay.querySelector(selector)?.getAttribute('d')).toContain( + 'M 400 166', + ), + ); + } finally { + getBoundingClientRect.mockRestore(); + } + }); + + it('routes a cyclic self-reference outside the resource card and moves it with the card', async () => { + const graph = graphFixture(); + const view = render( + overlayView( + graph, + [position('unrelated', 24, 32)], + new Set(['unrelated']), + ), + ); + const overlay = await screen.findByTestId('resource-dependency-overlay'); + const selfLoopSelector = + '[data-edge-kind="asset-reference"]' + + '[data-source-resource-id="unrelated"]' + + '[data-target-resource-id="unrelated"]'; + const selfLoop = await waitFor(() => { + const path = overlay.querySelector(selfLoopSelector); + expect(path).not.toBeNull(); + return path as SVGPathElement; + }); + + expect(selfLoop.getAttribute('data-cyclic')).toBe('true'); + expect(selfLoop.getAttribute('data-self-loop')).toBe('true'); + expect(selfLoop.getAttribute('d')).toBe( + 'M 204 96 C 260 96, 260 60, 204 60', + ); + expect(selfLoop.getAttribute('marker-end')).toContain( + 'asset-reference-arrow', + ); + + view.rerender( + overlayView( + graph, + [position('unrelated', 84, 48)], + new Set(['unrelated']), + ), + ); + await waitFor(() => + expect(selfLoop.getAttribute('d')).toBe( + 'M 264 112 C 320 112, 320 76, 264 76', + ), + ); + }); + + it('keeps every rendered relationship at its default visual state', async () => { + const graph = graphFixture(); + const positions = Array.from(graph.resourceIds).map((resourceId, index) => + position(resourceId, index * 200, 0), + ); + render(overlayView(graph, positions, new Set(graph.resourceIds))); + const overlay = await screen.findByTestId('resource-dependency-overlay'); + await waitFor(() => + expect(overlay.querySelectorAll('[data-edge-kind]')).toHaveLength(3), + ); + expect(overlay.querySelector('.is-highlighted')).toBeNull(); + expect(overlay.querySelector('.is-dimmed')).toBeNull(); + }); + + it('updates only adjacent paths while dragging inside a 4096-resource topology', async () => { + const resourceIds = Array.from( + { length: 4096 }, + (_, index) => `resource:${index}`, + ); + const taskFlows = resourceIds.slice(1).map((resourceId, index) => ({ + id: `flow:${index}:${index + 1}`, + kind: 'task-flow' as const, + sourceTaskId: `task:${index}`, + targetTaskId: `task:${index + 1}`, + sourceResourceIds: [`resource:${index}`], + targetResourceIds: [resourceId], + cyclic: false, + })); + const graph = normalizeProjectResourceGraph({ + resourceIds, + referenceEdges: [], + taskFlows, + connectionIndex: resourceIds.map((resourceId, index) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [ + ...(index > 0 ? [`flow:${index - 1}:${index}`] : []), + ...(index < resourceIds.length - 1 + ? [`flow:${index}:${index + 1}`] + : []), + ], + })), + producerAssignments: [], + dependencyDepths: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }); + const positions = [ + position('resource:2047', 0, 0), + position('resource:2048', 220, 0), + position('resource:2049', 440, 0), + ]; + const visible = new Set(positions.map(({ resourceId }) => resourceId)); + const overlayRef = React.createRef(); + render(overlayView(graph, positions, visible, overlayRef)); + const overlay = await screen.findByTestId('resource-dependency-overlay'); + await waitFor(() => + expect( + overlay.querySelectorAll('[data-edge-kind="task-flow"]'), + ).toHaveLength(2), + ); + const setAttribute = vi.spyOn(SVGElement.prototype, 'setAttribute'); + try { + act(() => + overlayRef.current?.updateDragPreview({ + resourceId: 'resource:2048', + x: 260, + y: 32, + }), + ); + const geometryUpdates = setAttribute.mock.calls.filter( + ([name]) => name === 'd', + ); + expect(geometryUpdates).toHaveLength(6); + } finally { + setAttribute.mockRestore(); + } + }); + + it('disconnects layout observers when the SVG layer is destroyed', () => { + const observe = vi.fn(); + const disconnect = vi.fn(); + class TestResizeObserver { + constructor(_callback: ResizeObserverCallback) {} + + observe = observe; + unobserve = vi.fn(); + disconnect = disconnect; + } + vi.stubGlobal('ResizeObserver', TestResizeObserver); + try { + const graph = graphFixture(); + const positions = Array.from(graph.resourceIds).map((resourceId, index) => + position(resourceId, index * 200, 0), + ); + const overlayRef = React.createRef(); + const view = render( + overlayView(graph, positions, new Set(graph.resourceIds), overlayRef), + ); + + expect(observe).toHaveBeenCalledTimes(2); + act(() => + overlayRef.current?.updateDragPreview({ + resourceId: 'source:one', + x: 32, + y: 24, + }), + ); + expect(observe).toHaveBeenCalledTimes(2); + view.unmount(); + expect(disconnect).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('uses a persistent orange with at least 3:1 canvas contrast', () => { + const styles = readFileSync( + resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), + 'utf8', + ); + expect(styles).toMatch( + /\.game-resource-dependency-edge--reference\s*\{[^}]*stroke:\s*#c45f20[^}]*opacity:\s*1/s, + ); + expect(styles).toMatch( + /\.game-resource-dependency-marker--reference path\s*\{[^}]*fill:\s*#c45f20/s, + ); + const luminance = (hex: string) => { + const channels = hex + .match(/[a-f\d]{2}/giu)! + .map((value) => Number.parseInt(value, 16) / 255) + .map((value) => + value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4, + ); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + }; + const lineLuminance = luminance('c45f20'); + const canvasLuminance = luminance('fffdfa'); + const contrast = + (Math.max(lineLuminance, canvasLuminance) + 0.05) / + (Math.min(lineLuminance, canvasLuminance) + 0.05); + expect(contrast).toBeGreaterThanOrEqual(3); + expect(styles).not.toMatch( + /\.game-resource-dependency-edge\.is-(?:highlighted|dimmed)/, + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 4d88b4f80..3fd45b7a7 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -334,10 +334,19 @@ function createProjectSupervisorRuntimeHarness({ runId: string; status: string; phase: string; + manifestInvalidated: boolean; runtime: Record; }; }) => void) | null = null; + let manifestInvalidatedHandler: + | ((event: { + payload: { + projectPath: string; + agentId: string; + }; + }) => void) + | null = null; const conversationRecord = ( role: 'user' | 'assistant', @@ -615,10 +624,16 @@ function createProjectSupervisorRuntimeHarness({ if (eventName === 'game-creator-agent-runtime-update') { runtimeUpdateHandler = handler; } + if (eventName === 'game-creator-manifest-invalidated') { + manifestInvalidatedHandler = handler as unknown as typeof manifestInvalidatedHandler; + } return () => { if (runtimeUpdateHandler === handler) { runtimeUpdateHandler = null; } + if (manifestInvalidatedHandler === handler) { + manifestInvalidatedHandler = null; + } }; }, ); @@ -667,6 +682,7 @@ function createProjectSupervisorRuntimeHarness({ runId: String(state.runId), status: String(state.status), phase: String(state.phase), + manifestInvalidated: true, runtime: runtimeResult(currentRuntime, currentResponseStream), }, }); @@ -679,10 +695,19 @@ function createProjectSupervisorRuntimeHarness({ runId: String(state.runId ?? ''), status: String(state.status ?? ''), phase: String(state.phase ?? ''), + manifestInvalidated: true, runtime: runtimeResult(state, null), }, }); }, + emitManifestInvalidated(agentId: string) { + manifestInvalidatedHandler?.({ + payload: { + projectPath, + agentId, + }, + }); + }, }; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 733c41033..1ff1f578f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1,5 +1,8 @@ +import type { ProjectSupervisorComponentProps } from '../../src/features/app-shell/model'; +import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher'; import { act, + App, cleanup, createGameCreationAppManifest, createProjectSupervisorRuntimeHarness, @@ -7,18 +10,485 @@ import { fireEvent, it, nativeClipboardMock, + React, + render, renderAppAt, renderLauncherAgentChatAt, renderLauncherAt, renderLauncherProjectsAt, screen, selectDeveloperAgentChatMode, + testAuthUser, vi, waitFor, within, } from './harness'; export function registerClientHomeTests() { + it('projects Supervisor manifest updates into the open workbench without reopening the project', async () => { + const projectPath = '/tmp/live-manifest-workbench'; + const initialManifest = createGameCreationAppManifest( + 'live-manifest-project', + '实时清单项目', + ); + const updatedManifest = { + ...initialManifest, + tasks: initialManifest.tasks.map((task) => + task.id === 'code-prototype' + ? { ...task, status: 'completed' as const } + : task, + ), + assets: [ + { + id: 'live-hero', + kind: 'art-spritesheet', + mediaType: 'image/png', + localPath: 'assets/live-hero.png', + source: { kind: 'generated' as const, taskId: 'art-asset-plan' }, + }, + ], + versions: [ + { + versionId: 'version-live-1', + parentVersionId: null, + projectRevision: 1, + resourceBindings: [{ slotId: 'hero', resourceId: 'live-hero' }], + createdReason: 'initial' as const, + createdAt: 1, + }, + ], + }; + function ManifestPushingSupervisor({ + initialProjectPath, + onManifestChange, + }: ProjectSupervisorComponentProps) { + return React.createElement( + 'button', + { + type: 'button', + onClick: () => + onManifestChange?.(initialProjectPath ?? '', updatedManifest), + }, + '同步最新 manifest', + ); + } + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: '实时清单项目', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return initialManifest; + } + if (command === 'read_local_project_resource_graph') { + return { + resourceIds: (args?.resources as Array<{ resourceId: string }>).map( + (resource) => resource.resourceId, + ), + referenceEdges: [], + taskFlows: [], + connectionIndex: [], + producerAssignments: [], + dependencyDepths: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'live-manifest-project', + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'live-manifest-project', + mode: 'dependency', + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + render( + React.createElement(WorkspaceLauncherShell, { + currentUser: testAuthUser, + initialView: 'projects', + onLogout: vi.fn(), + ProjectSupervisor: ManifestPushingSupervisor, + }), + ); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: projectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + const runButton = await screen.findByRole('tab', { name: '运行' }); + expect(runButton.getAttribute('data-unavailable')).toBe('true'); + expect(screen.queryByRole('button', { name: /live-hero\.png/ })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '同步最新 manifest' })); + + expect( + await screen.findByRole('button', { name: /live-hero\.png/ }), + ).not.toBeNull(); + expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull(); + expect(runButton.getAttribute('data-unavailable')).toBeNull(); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'read_local_project_resource_graph', + expect.objectContaining({ + resources: expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:live-hero', + manifestAssetId: 'live-hero', + }), + expect.objectContaining({ + resourceId: 'version:version-live-1', + }), + ]), + }), + ); + }); + }); + + it('re-reads the live manifest from a non-Supervisor Runtime event without reopening the project', async () => { + const projectPath = '/tmp/live-runtime-manifest-workbench'; + const initialManifest = createGameCreationAppManifest( + 'live-runtime-manifest-project', + '真实事件清单项目', + ); + const updatedManifest = { + ...initialManifest, + tasks: initialManifest.tasks.map((task) => + task.id === 'code-prototype' + ? { ...task, status: 'completed' as const } + : task, + ), + assets: [ + { + id: 'runtime-live-hero', + kind: 'art-spritesheet', + mediaType: 'image/png', + localPath: 'assets/runtime-live-hero.png', + source: { + kind: 'canvas' as const, + taskId: 'art-asset-plan', + resourceId: 'canvas-runtime-live-hero', + }, + }, + ], + versions: [ + { + versionId: 'version-runtime-live-1', + parentVersionId: null, + projectRevision: 1, + resourceBindings: [ + { slotId: 'hero', resourceId: 'runtime-live-hero' }, + ], + createdReason: 'initial' as const, + createdAt: 1, + }, + ], + }; + const runtimeHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + let manifestChanged = false; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: '真实事件清单项目', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifestChanged ? updatedManifest : initialManifest; + } + if (command === 'read_local_project_resource_graph') { + return { + resourceIds: (args?.resources as Array<{ resourceId: string }>).map( + (resource) => resource.resourceId, + ), + referenceEdges: [], + taskFlows: [], + connectionIndex: [], + producerAssignments: [], + dependencyDepths: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'live-runtime-manifest-project', + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'live-runtime-manifest-project', + mode: args?.mode, + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + return runtimeHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: runtimeHarness.listen }, + }; + render( + React.createElement(WorkspaceLauncherShell, { + currentUser: testAuthUser, + initialView: 'projects', + onLogout: vi.fn(), + ProjectSupervisor: App, + }), + ); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: projectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + const runButton = await screen.findByRole('tab', { name: '运行' }); + expect(runButton.getAttribute('data-unavailable')).toBe('true'); + expect( + screen.queryByRole('button', { name: /runtime-live-hero\.png/ }), + ).toBeNull(); + const manifestReadsBeforeEvent = invoke.mock.calls.filter( + ([command]) => command === 'get_local_game_manifest', + ).length; + const inspectionsBeforeEvent = invoke.mock.calls.filter( + ([command]) => command === 'inspect_local_project_directory', + ).length; + + manifestChanged = true; + act(() => { + runtimeHarness.emitManifestInvalidated('art-asset-plan'); + }); + + expect( + await screen.findByRole('button', { name: /runtime-live-hero\.png/ }), + ).not.toBeNull(); + expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull(); + expect(runButton.getAttribute('data-unavailable')).toBeNull(); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'get_local_game_manifest', + ).length, + ).toBeGreaterThan(manifestReadsBeforeEvent); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'inspect_local_project_directory', + ).length, + ).toBe(inspectionsBeforeEvent); + }); + + it('does not let a late manifest refresh from the previous project replace the active project', async () => { + const firstProjectPath = '/tmp/live-manifest-project-first'; + const secondProjectPath = '/tmp/live-manifest-project-second'; + const firstManifest = createGameCreationAppManifest( + 'live-manifest-project-first', + '旧项目', + ); + const staleFirstManifest = { + ...firstManifest, + assets: [ + { + id: 'stale-first-asset', + kind: 'art-spritesheet', + mediaType: 'image/png', + localPath: 'assets/stale-first.png', + source: { kind: 'generated' as const }, + }, + ], + }; + const secondManifest = createGameCreationAppManifest( + 'live-manifest-project-second', + '新项目', + ); + secondManifest.assets = [ + { + id: 'second-asset', + kind: 'art-spritesheet', + mediaType: 'image/png', + localPath: 'assets/second.png', + source: { kind: 'generated' }, + }, + ]; + let resolveStaleRefresh!: ( + manifest: typeof staleFirstManifest, + ) => void; + const staleRefresh = new Promise((resolve) => { + resolveStaleRefresh = resolve; + }); + let holdFirstRefresh = false; + let signalFirstRefreshStarted!: () => void; + const firstRefreshStarted = new Promise((resolve) => { + signalFirstRefreshStarted = resolve; + }); + const runtimeHarness = createProjectSupervisorRuntimeHarness({ + projectPath: firstProjectPath, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + const requestedPath = String(args?.projectPath ?? ''); + if (command === 'inspect_local_project_directory') { + return { + projectPath: requestedPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: + requestedPath === secondProjectPath ? '新项目' : '旧项目', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + if (requestedPath === secondProjectPath) { + return secondManifest; + } + if (holdFirstRefresh) { + signalFirstRefreshStarted(); + return staleRefresh; + } + return firstManifest; + } + if (command === 'read_local_project_resource_graph') { + return { + resourceIds: (args?.resources as Array<{ resourceId: string }>).map( + (resource) => resource.resourceId, + ), + referenceEdges: [], + taskFlows: [], + connectionIndex: [], + producerAssignments: [], + dependencyDepths: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: + requestedPath === secondProjectPath + ? secondManifest.projectId + : firstManifest.projectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: String(args?.expectedProjectId ?? ''), + mode: args?.mode, + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + return runtimeHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: runtimeHarness.listen }, + }; + render( + React.createElement(WorkspaceLauncherShell, { + currentUser: testAuthUser, + initialView: 'projects', + onLogout: vi.fn(), + ProjectSupervisor: App, + }), + ); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: firstProjectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + await screen.findByLabelText('项目开发工作台'); + holdFirstRefresh = true; + act(() => { + runtimeHarness.emitManifestInvalidated('code-prototype'); + }); + await firstRefreshStarted; + + fireEvent.click(screen.getByRole('button', { name: '项目组' })); + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: secondProjectPath }, + }); + fireEvent.click( + within(screen.getByLabelText('项目目录').closest('form')!).getByRole( + 'button', + { name: '打开' }, + ), + ); + expect( + await screen.findByRole('button', { name: /second\.png/ }), + ).not.toBeNull(); + + act(() => resolveStaleRefresh(staleFirstManifest)); + await Promise.resolve(); + expect( + screen.queryByRole('button', { name: /stale-first\.png/ }), + ).toBeNull(); + expect(screen.getByRole('button', { name: /second\.png/ })).not.toBeNull(); + }); + it('starts from the client home and opens a project in the same window', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') @@ -801,7 +1271,9 @@ export function registerHomeProjectCreationTests() { expect(screen.getByLabelText('项目总控消息').textContent).toContain( '第一行\n第二行\n第三行', ); - expect(screen.getByText('assets/uploads/reference.png')).not.toBeNull(); + expect( + await screen.findByText('assets/uploads/reference.png'), + ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('init_local_game_project', { projectPath: '/tmp/home-created-game', projectId: 'local-project-draft', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 41d5254b6..411599c3c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -57,6 +57,43 @@ import { within, } from './harness'; +function resourceGraphForInputs(args?: Record) { + const resources = + (args?.resources as + | Array<{ resourceId: string; producerTaskId: string | null }> + | undefined) ?? []; + return { + resourceIds: resources.map(({ resourceId }) => resourceId), + referenceEdges: [], + taskFlows: [], + connectionIndex: resources.map(({ resourceId }) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: resources.flatMap((resource) => + resource.producerTaskId + ? [ + { + resourceId: resource.resourceId, + taskId: resource.producerTaskId, + }, + ] + : [], + ), + dependencyDepths: resources.map((resource) => ({ + resourceId: resource.resourceId, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + function gameChatRuntimeEvent({ agentId = 'project-supervisor', taskId = agentId, @@ -267,7 +304,11 @@ async function renderGameChatAutoPreviewDriver({ callIndex: number, ) => Promise; }) { - const harness = createProjectSupervisorRuntimeHarness({ projectPath }); + let backgroundRuntimes: Array> = []; + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + runtimeMapLoader: async () => backgroundRuntimes, + }); const manifest = createGameCreationAppManifest( projectPath.split(/[\\/]/u).filter(Boolean).at(-1) ?? 'game-chat-race', 'game-chat-race', @@ -384,14 +425,14 @@ async function renderGameChatAutoPreviewDriver({ }; const emitValidation = async (revision: number, validatedAt: number) => { harness.setProjectRevision(revision); + const runtime = gameChatPreviewPlaytestRuntime({ + parentRunId, + revision, + updatedAt: validatedAt, + }); + backgroundRuntimes = [runtime]; await act(async () => { - harness.emitAgentRuntime( - gameChatPreviewPlaytestRuntime({ - parentRunId, - revision, - updatedAt: validatedAt, - }), - ); + harness.emitAgentRuntime(runtime); await Promise.resolve(); }); }; @@ -468,7 +509,7 @@ async function assertNewGameChatAuthorizationSupersedesDeferredAttempt( : 'start_local_game_preview'), ); expect(reachedDeferredStage).toBe(true); - }); + }, { timeout: 2_500 }); const firstAuthorization = driver.readAuthorization(); expect(firstAuthorization?.authorizationId).toEqual(expect.any(String)); @@ -644,15 +685,107 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.getByRole('article', { name: /发布 Agent/ })).not.toBeNull(); }); - it('keeps project chrome out, moves resources by pointer, and contains text receipts in a dialog', () => { + it('renders immutable manifest versions, their parent graph, and bound asset highlights', async () => { const manifest = createGameCreationAppManifest( - 'workbench-resource-drag', + 'workbench-versions', + '版本工作台测试', + ); + manifest.assets = [ + { + id: 'asset-player', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/player.png', + source: { kind: 'generated' }, + }, + ]; + manifest.versions = [ + { + versionId: 'version-root', + parentVersionId: null, + projectRevision: 3, + resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }], + createdReason: 'initial', + createdAt: 100, + }, + { + versionId: 'version-child', + parentVersionId: 'version-root', + projectRevision: 4, + resourceBindings: [ + { slotId: 'player', resourceId: 'asset-player' }, + { slotId: 'historical', resourceId: 'asset-removed' }, + ], + createdReason: 'agent-revision', + createdAt: 200, + }, + ]; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-versions', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + const resourceCards = screen.getAllByTitle('打开资源详情'); + const rootVersionCard = resourceCards.find((card) => + card.textContent?.includes('版本 1'), + ); + const childVersionCard = resourceCards.find((card) => + card.textContent?.includes('版本 2'), + ); + expect(rootVersionCard?.textContent).toContain('1 个直接子版本'); + expect(childVersionCard?.textContent).toContain('父版本 version-root'); + + fireEvent.click(childVersionCard!); + const versionFocus = screen.getByRole('region', { name: '版本 2' }); + expect(within(versionFocus).getByText('version-child')).not.toBeNull(); + expect(within(versionFocus).getByText('version-root')).not.toBeNull(); + expect(within(versionFocus).getByText('Agent 修订')).not.toBeNull(); + expect( + within(versionFocus).getByText( + 'player → asset-player;historical → asset-removed', + ), + ).not.toBeNull(); + expect(screen.queryByText('asset:asset-removed')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '收起资源' })); + const playerCard = screen + .getAllByTitle('打开资源详情') + .find((card) => card.textContent?.includes('player.png')); + expect(playerCard?.classList.contains('is-relation-version-binding')).toBe( + true, + ); + + fireEvent.click(screen.getByRole('button', { name: '按依赖' })); + await waitFor(() => { + const dependencyPlayerCard = screen + .getAllByTitle('打开资源详情') + .find((card) => card.textContent?.includes('player.png')); + expect( + dependencyPlayerCard?.classList.contains('is-relation-version-binding'), + ).toBe(true); + }); + }); + + it('opens text receipts in the central focus state and restores the resource list context', () => { + const manifest = createGameCreationAppManifest( + 'workbench-resource-details', '不应显示的项目标题', ); render( React.createElement(ProjectDevelopmentView, { projectName: '不应显示的项目标题', - projectPath: '/tmp/workbench-resource-drag', + projectPath: '/tmp/workbench-resource-details', manifest, attachments: [], recentRunStatus: null, @@ -703,11 +836,23 @@ export function registerProjectWorkbenchFoundationTests() { screen.getByText('美术资源计划已完成,尚未生成或登记图片'), ).not.toBeNull(); - const resourceCards = screen.getAllByTitle('拖动调整资源位置'); + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + const searchInput = screen.getByLabelText( + '搜索项目资源', + ) as HTMLInputElement; + fireEvent.change(searchInput, { target: { value: '美术资源计划' } }); + const resourceCanvas = screen.getByLabelText( + '资源类型视图', + ) as HTMLDivElement; + resourceCanvas.scrollLeft = 48; + resourceCanvas.scrollTop = 36; + + const resourceCards = screen.getAllByTitle('打开资源详情'); const artReceiptCard = resourceCards.find((card) => card.textContent?.includes('美术资源计划 Agent 文本回执'), ); expect(artReceiptCard).not.toBeUndefined(); + const originalStyle = artReceiptCard?.getAttribute('style'); fireEvent.pointerDown(artReceiptCard!, { pointerId: 7, button: 0, @@ -724,31 +869,1031 @@ export function registerProjectWorkbenchFoundationTests() { clientX: 240, clientY: 32, }); - expect(artReceiptCard?.getAttribute('style')).toContain( - '--resource-x: 240px', + fireEvent.pointerCancel(artReceiptCard!, { pointerId: 7 }); + expect(artReceiptCard?.getAttribute('style')).toBe(originalStyle); + expect(artReceiptCard?.classList.contains('is-dragging')).toBe(false); + const styles = readFileSync( + resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), + 'utf8', ); + expect(styles).toMatch( + /\.game-resource-card\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s, + ); + expect(styles).not.toMatch(/\.game-resource-card\.is-dragging/); fireEvent.click(artReceiptCard!); - fireEvent.click(artReceiptCard!); - const receiptDialog = screen.getByRole('dialog', { + const workbenchStage = screen.getByLabelText('项目主视窗'); + expect(workbenchStage.getAttribute('data-resource-view-state')).toBe( + 'resources.focused.document', + ); + expect(screen.queryByLabelText('搜索项目资源')).toBeNull(); + expect(screen.queryByRole('button', { name: '按类型' })).toBeNull(); + expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull(); + expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull(); + const receiptFocus = screen.getByRole('region', { name: '美术资源计划 Agent 文本回执', }); expect( - within(receiptDialog).getByRole('heading', { name: '美术计划' }), + within(receiptFocus).getByRole('heading', { name: '美术计划' }), ).not.toBeNull(); - expect(within(receiptDialog).getByText('仅有美术计划')).not.toBeNull(); - expect(within(receiptDialog).getByText('没有图片文件')).not.toBeNull(); + expect(within(receiptFocus).getByText('仅有美术计划')).not.toBeNull(); + expect(within(receiptFocus).getByText('没有图片文件')).not.toBeNull(); + expect(within(receiptFocus).getByText('等待实际素材生成。')).not.toBeNull(); expect( - within(receiptDialog).getByText('等待实际素材生成。'), + receiptFocus.querySelector('.game-resource-focus-body'), ).not.toBeNull(); + expect(receiptFocus.querySelector('ul')).not.toBeNull(); + expect(receiptFocus.querySelector('strong')).not.toBeNull(); + expect(receiptFocus.closest('.game-workbench-stage')).toBe(workbenchStage); + expect(receiptFocus.closest('.game-resource-focus-layer')).toBeNull(); + expect(styles).not.toMatch( + /\.game-resource-focus-titlebar\s*\{[^}]*cursor:/s, + ); + + fireEvent.click( + within(receiptFocus).getByRole('button', { name: '收起资源' }), + ); + expect(workbenchStage.getAttribute('data-resource-view-state')).toBe( + 'resources.list', + ); expect( - receiptDialog.querySelector('.game-resource-focus-body'), + (screen.getByLabelText('搜索项目资源') as HTMLInputElement).value, + ).toBe('美术资源计划'); + expect( + screen + .getByRole('button', { name: '按类型' }) + .getAttribute('aria-pressed'), + ).toBe('true'); + const restoredCanvas = screen.getByLabelText( + '资源类型视图', + ) as HTMLDivElement; + expect(restoredCanvas.scrollLeft).toBe(48); + expect(restoredCanvas.scrollTop).toBe(36); + const restoredCard = screen + .getAllByTitle('打开资源详情') + .find((card) => + card.textContent?.includes('美术资源计划 Agent 文本回执'), + ); + expect(restoredCard?.getAttribute('aria-pressed')).toBe('true'); + expect(document.activeElement).toBe(restoredCard); + fireEvent.click(restoredCard!); + expect( + screen.getByRole('region', { + name: '美术资源计划 Agent 文本回执', + }), ).not.toBeNull(); - expect(receiptDialog.querySelector('ul')).not.toBeNull(); - expect(receiptDialog.querySelector('strong')).not.toBeNull(); + fireEvent.keyDown(window, { key: 'Escape' }); + const escapeRestoredCard = screen + .getAllByTitle('打开资源详情') + .find((card) => + card.textContent?.includes('美术资源计划 Agent 文本回执'), + ); + expect(document.activeElement).toBe(escapeRestoredCard); }); - it('persists a resource position with CAS and restores it after remount', async () => { + it('loads registered documents, art media, video, and audio with safe failure states inside central focus', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-resource-media', + '资源媒体测试', + ); + manifest.assets.push( + { + id: 'design-document', + kind: 'design-document', + mediaType: 'text/markdown', + localPath: 'game/design.md', + source: { kind: 'generated' }, + }, + { + id: 'art-svg', + kind: 'icon', + mediaType: 'image/svg+xml', + localPath: 'assets/icon.svg', + source: { kind: 'generated' }, + }, + { + id: 'art-video', + kind: 'animation', + mediaType: 'video/mp4', + localPath: 'assets/intro.mp4', + source: { kind: 'generated' }, + }, + { + id: 'audio-bgm', + kind: 'bgm', + mediaType: 'audio/mpeg', + localPath: 'assets/bgm.mp3', + source: { kind: 'generated' }, + }, + { + id: 'blocked-document', + kind: 'design-document', + mediaType: 'text/markdown', + localPath: 'game/blocked.md', + source: { kind: 'generated' }, + }, + ); + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: manifest.projectId, + mode: args?.mode, + revision: layoutRevision, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: manifest.projectId, + mode: args?.mode, + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + if (command === 'read_local_project_text_preview') { + if (args?.relativePath === 'game/blocked.md') { + throw new Error('项目权限策略要求用户确认:file.read'); + } + expect(args).toMatchObject({ + projectPath: '/tmp/workbench-resource-media', + relativePath: 'game/design.md', + }); + return { + path: 'game/design.md', + mediaType: 'text/markdown', + byteLen: 64, + content: + '# 本地设计文档\n\n[外部链接](https://example.com)\n\n![远程图片](https://example.com/image.png)\n\n', + }; + } + if (command === 'read_local_project_media_preview') { + if (args?.category === 'art') { + if (args?.relativePath === 'assets/intro.mp4') { + return { + path: 'assets/intro.mp4', + mediaType: 'video/mp4', + byteLen: 128, + dataUrl: 'data:video/mp4;base64,AAAAIGZ0eXA=', + }; + } + return { + path: 'assets/icon.svg', + mediaType: 'image/svg+xml', + byteLen: 48, + dataUrl: 'data:image/svg+xml;base64,PHN2Zy8+', + }; + } + return { + path: 'assets/bgm.mp3', + mediaType: 'audio/mpeg', + byteLen: 1024, + dataUrl: 'data:audio/mpeg;base64,SUQz', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: '资源媒体测试', + projectPath: '/tmp/workbench-resource-media', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + fireEvent.click(await screen.findByRole('button', { name: /design\.md/ })); + const documentFocus = await screen.findByRole('region', { + name: 'design.md', + }); + expect( + within(documentFocus).getByRole('heading', { name: '本地设计文档' }), + ).not.toBeNull(); + expect(documentFocus.querySelector('script')).toBeNull(); + expect(documentFocus.querySelector('a')).toBeNull(); + expect(within(documentFocus).getByText('图片:远程图片')).not.toBeNull(); + fireEvent.click( + within(documentFocus).getByRole('button', { name: '收起资源' }), + ); + + fireEvent.click(screen.getByRole('button', { name: /icon\.svg/ })); + const artPreview = await screen.findByLabelText('icon.svg 美术媒体预览'); + await waitFor(() => { + expect(artPreview.querySelector('img')?.getAttribute('src')).toBe( + 'data:image/svg+xml;base64,PHN2Zy8+', + ); + }); + fireEvent.click( + within(screen.getByRole('region', { name: 'icon.svg' })).getByRole( + 'button', + { name: '收起资源' }, + ), + ); + + fireEvent.click(screen.getByRole('button', { name: /intro\.mp4/ })); + const video = (await screen.findByLabelText( + 'intro.mp4 视频预览', + )) as HTMLVideoElement; + expect(video.controls).toBe(true); + expect(video.preload).toBe('metadata'); + expect(video.getAttribute('src')).toBe( + 'data:video/mp4;base64,AAAAIGZ0eXA=', + ); + fireEvent.click( + within(screen.getByRole('region', { name: 'intro.mp4' })).getByRole( + 'button', + { name: '收起资源' }, + ), + ); + + fireEvent.click(screen.getByRole('button', { name: /bgm\.mp3/ })); + const audio = (await screen.findByLabelText( + 'bgm.mp3 音频播放器', + )) as HTMLAudioElement; + expect(audio.controls).toBe(true); + expect(audio.getAttribute('src')).toBe('data:audio/mpeg;base64,SUQz'); + expect(screen.getByText('载入后显示')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith( + 'read_local_project_media_preview', + expect.objectContaining({ category: 'audio' }), + ); + fireEvent.click( + within(screen.getByRole('region', { name: 'bgm.mp3' })).getByRole( + 'button', + { name: '收起资源' }, + ), + ); + + fireEvent.click(screen.getByRole('button', { name: /blocked\.md/ })); + expect((await screen.findByRole('alert')).textContent).toBe( + '当前项目策略要求先确认读取资源', + ); + expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull(); + expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull(); + }); + + it('preserves internal media focus across same-resource manifest updates and falls back when the resource is deleted', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-resource-focus-updates', + '资源焦点更新测试', + ); + manifest.assets = [ + { + id: 'focus-audio', + kind: 'background-music', + mediaType: 'audio/mpeg', + localPath: 'assets/focus.mp3', + source: { kind: 'generated' }, + }, + ]; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: manifest.projectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: manifest.projectId, + mode: args?.mode, + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + if (command === 'read_local_project_media_preview') { + return { + path: 'assets/focus.mp3', + mediaType: 'audio/mpeg', + byteLen: 1024, + dataUrl: 'data:audio/mpeg;base64,SUQz', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const viewProps = { + projectName: manifest.name, + projectPath: '/tmp/workbench-resource-focus-updates', + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }; + const rendered = render( + React.createElement(ProjectDevelopmentView, { + ...viewProps, + manifest, + }), + ); + + fireEvent.click( + await screen.findByRole('button', { name: /focus\.mp3/ }), + ); + const audio = (await screen.findByLabelText( + 'focus.mp3 音频播放器', + )) as HTMLAudioElement; + audio.focus(); + expect(document.activeElement).toBe(audio); + + const updatedManifest = { + ...manifest, + tasks: manifest.tasks.map((task) => + task.id === 'audio-director' + ? { ...task, status: 'completed' as const } + : task, + ), + assets: manifest.assets.map((asset) => ({ + ...asset, + source: { ...asset.source, taskId: 'audio-director' }, + })), + }; + rendered.rerender( + React.createElement(ProjectDevelopmentView, { + ...viewProps, + manifest: updatedManifest, + }), + ); + + expect(document.activeElement).toBe(audio); + expect( + screen.getByRole('region', { name: 'focus.mp3' }), + ).not.toBe(document.activeElement); + + rendered.rerender( + React.createElement(ProjectDevelopmentView, { + ...viewProps, + manifest: { ...updatedManifest, assets: [] }, + }), + ); + + await waitFor(() => { + expect(screen.queryByRole('region', { name: 'focus.mp3' })).toBeNull(); + expect(document.activeElement).toBe( + screen.getByLabelText('搜索项目资源'), + ); + }); + expect( + screen + .queryAllByTitle('打开资源详情') + .some((card) => card.getAttribute('aria-pressed') === 'true'), + ).toBe(false); + }); + + it('renders, filters, and destroys persistent resource dependency lines without cross-section task flows', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-resource-graph', + '资源依赖图测试', + ); + manifest.assets.push( + { + id: 'dependency-spec', + kind: 'design-spec', + mediaType: 'application/json', + localPath: 'assets/spec-source.json', + source: { + kind: 'canvas', + taskId: 'task-1', + resourceId: 'canvas-spec-source', + }, + }, + { + id: 'dependency-ui', + kind: 'ui-prototype', + mediaType: 'application/json', + localPath: 'assets/ui-dependency.json', + source: { + kind: 'canvas', + taskId: 'task-2', + resourceId: 'canvas-ui-target', + referenceResourceIds: ['canvas-spec-source'], + }, + }, + { + id: 'unrelated-cycle', + kind: 'metadata', + mediaType: 'application/json', + localPath: 'assets/unrelated-cycle.json', + source: { + kind: 'canvas', + resourceId: 'canvas-unrelated', + referenceResourceIds: ['canvas-unrelated'], + }, + }, + ); + + const referenceId = + 'asset-reference:["asset:dependency-spec","asset:dependency-ui"]'; + const selfReferenceId = + 'asset-reference:["asset:unrelated-cycle","asset:unrelated-cycle"]'; + const flowId = 'task-flow:["art-director","design-foundation"]'; + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + expect(args?.resources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:dependency-spec', + manifestAssetId: 'dependency-spec', + producerTaskId: null, + }), + ]), + ); + return { + resourceIds: [ + 'asset:dependency-spec', + 'asset:dependency-ui', + 'asset:unrelated-cycle', + ], + referenceEdges: [ + { + id: referenceId, + kind: 'asset-reference', + sourceResourceId: 'asset:dependency-spec', + targetResourceId: 'asset:dependency-ui', + cyclic: false, + }, + { + id: selfReferenceId, + kind: 'asset-reference', + sourceResourceId: 'asset:unrelated-cycle', + targetResourceId: 'asset:unrelated-cycle', + cyclic: true, + }, + ], + taskFlows: [ + { + id: flowId, + kind: 'task-flow', + sourceTaskId: 'art-director', + targetTaskId: 'design-foundation', + sourceResourceIds: ['asset:dependency-spec'], + targetResourceIds: ['asset:dependency-ui'], + cyclic: false, + }, + ], + connectionIndex: [ + { + resourceId: 'asset:dependency-spec', + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: ['asset:dependency-ui'], + referenceEdgeIds: [referenceId], + taskFlowIds: [flowId], + }, + { + resourceId: 'asset:dependency-ui', + upstreamReferenceResourceIds: ['asset:dependency-spec'], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [referenceId], + taskFlowIds: [flowId], + }, + { + resourceId: 'asset:unrelated-cycle', + upstreamReferenceResourceIds: ['asset:unrelated-cycle'], + downstreamReferenceResourceIds: ['asset:unrelated-cycle'], + referenceEdgeIds: [selfReferenceId], + taskFlowIds: [], + }, + ], + producerAssignments: [ + { + resourceId: 'asset:dependency-spec', + taskId: 'art-director', + }, + { + resourceId: 'asset:dependency-ui', + taskId: 'design-foundation', + }, + ], + dependencyDepths: [ + { + resourceId: 'asset:dependency-spec', + dependencyDepth: 0, + }, + { + resourceId: 'asset:dependency-ui', + dependencyDepth: 1, + }, + { + resourceId: 'asset:unrelated-cycle', + dependencyDepth: 0, + }, + ], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: ['asset:unrelated-cycle'], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'workbench-resource-graph', + mode: args?.mode, + revision: layoutRevision, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'workbench-resource-graph', + mode: args?.mode, + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + const view = render( + React.createElement(ProjectDevelopmentView, { + projectName: '资源依赖图测试', + projectPath: '/tmp/workbench-resource-graph', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + let overlay = await screen.findByTestId('resource-dependency-overlay'); + await waitFor(() => { + expect( + overlay.querySelectorAll('[data-edge-kind="asset-reference"]'), + ).toHaveLength(2); + expect( + overlay.querySelectorAll('[data-edge-kind="task-flow"]'), + ).toHaveLength(0); + }); + const dependencyCanvas = screen.getByLabelText('资源依赖视图'); + const descriptionId = dependencyCanvas.getAttribute('aria-describedby'); + expect(descriptionId).not.toBeNull(); + const relationshipDescription = document.getElementById(descriptionId!); + expect(relationshipDescription?.textContent).toContain( + 'ui-dependency.json(待视觉验收) 引用 spec-source.json', + ); + expect(overlay.getAttribute('aria-hidden')).toBe('true'); + + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + expect(screen.queryByTestId('resource-dependency-overlay')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '按依赖' })); + overlay = await screen.findByTestId('resource-dependency-overlay'); + + const search = screen.getByLabelText('搜索项目资源'); + fireEvent.change(search, { target: { value: 'ui-dependency' } }); + await waitFor(() => { + expect(overlay.querySelector('[data-edge-kind]')).toBeNull(); + }); + fireEvent.change(search, { target: { value: '' } }); + + let sourceCard = screen.getByRole('button', { + name: /spec-source\.json/, + }); + const targetCard = screen.getByRole('button', { + name: /ui-dependency\.json/, + }); + const referenceSelector = + '[data-edge-kind="asset-reference"]' + + '[data-source-resource-id="asset:dependency-spec"]' + + '[data-target-resource-id="asset:dependency-ui"]'; + const firstPath = await waitFor(() => { + const path = overlay.querySelector(referenceSelector); + expect(path).not.toBeNull(); + return path?.getAttribute('d'); + }); + const sourceStyle = sourceCard.getAttribute('style'); + const layoutUpdatesBeforePointer = invoke.mock.calls.filter( + ([command]) => command === 'update_local_project_resource_canvas_layout', + ).length; + + fireEvent.pointerDown(sourceCard, { + pointerId: 27, + button: 0, + clientX: 0, + clientY: 0, + }); + fireEvent.pointerMove(sourceCard, { + pointerId: 27, + clientX: 72, + clientY: 28, + }); + fireEvent.pointerUp(sourceCard, { + pointerId: 27, + clientX: 72, + clientY: 28, + }); + fireEvent.pointerCancel(sourceCard, { pointerId: 27 }); + expect(sourceCard.getAttribute('style')).toBe(sourceStyle); + expect(sourceCard.classList.contains('is-dragging')).toBe(false); + expect(overlay.querySelector(referenceSelector)?.getAttribute('d')).toBe( + firstPath, + ); + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'update_local_project_resource_canvas_layout', + ), + ).toHaveLength(layoutUpdatesBeforePointer); + + fireEvent.click(targetCard); + const targetFocus = screen.getByRole('region', { + name: /ui-dependency\.json/u, + }); + fireEvent.click( + within(targetFocus).getByRole('button', { name: '收起资源' }), + ); + overlay = await screen.findByTestId('resource-dependency-overlay'); + sourceCard = screen.getByRole('button', { name: /spec-source\.json/ }); + await waitFor(() => + expect(overlay.querySelector(referenceSelector)).not.toBeNull(), + ); + expect(sourceCard.classList.contains('is-relation-upstream')).toBe(false); + expect( + overlay + .querySelector(referenceSelector) + ?.classList.contains('is-highlighted'), + ).toBe(false); + expect( + overlay + .querySelector('[data-source-resource-id="asset:unrelated-cycle"]') + ?.classList.contains('is-dimmed'), + ).toBe(false); + + const previousOverlay = overlay; + const nextManifest = createGameCreationAppManifest( + 'workbench-resource-graph-next', + '新资源依赖图测试', + ); + view.rerender( + React.createElement(ProjectDevelopmentView, { + projectName: '新资源依赖图测试', + projectPath: '/tmp/workbench-resource-graph-next', + manifest: nextManifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + const nextOverlay = await screen.findByTestId( + 'resource-dependency-overlay', + ); + expect(nextOverlay).not.toBe(previousOverlay); + expect(previousOverlay.isConnected).toBe(false); + expect(nextOverlay.querySelector('[data-edge-kind]')).toBeNull(); + }); + + it('waits for the scoped resource graph before initializing dependency layout', async () => { + const projectId = 'workbench-delayed-resource-graph'; + const projectPath = '/tmp/workbench-delayed-resource-graph'; + const manifest = createGameCreationAppManifest(projectId, '延迟依赖图测试'); + const agentResults = [ + { + agentId: 'design-foundation', + runId: 'delayed-graph-run', + label: '玩法策划 Agent', + title: '延迟依赖图回执', + content: '图就绪后再初始化布局', + updatedAt: 1, + }, + ]; + let resolveGraph: (() => void) | null = null; + let layoutReads = 0; + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return await new Promise((resolve) => { + resolveGraph = () => resolve(resourceGraphForInputs(args)); + }); + } + if (command === 'read_local_project_resource_canvas_layout') { + layoutReads += 1; + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: 'dependency', + revision: layoutRevision, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: 'dependency', + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: '延迟依赖图测试', + projectPath, + manifest, + attachments: [], + agentResults, + supervisor: React.createElement('div', null, '项目总控'), + }), + ); + + await waitFor(() => expect(resolveGraph).not.toBeNull()); + expect(layoutReads).toBe(0); + expect(screen.queryByText('延迟依赖图回执')).toBeNull(); + + await act(async () => { + resolveGraph?.(); + await Promise.resolve(); + }); + expect(await screen.findByText('延迟依赖图回执')).not.toBeNull(); + expect(layoutReads).toBe(1); + }); + + it('keeps trusted truncated-graph depths through the workbench without persisting a flat automatic layout', async () => { + const projectId = 'workbench-truncated-resource-graph'; + const projectPath = '/tmp/workbench-truncated-resource-graph'; + const manifest = createGameCreationAppManifest( + projectId, + '截断依赖图布局保护测试', + ); + manifest.assets.push( + { + id: 'truncated-depth-0', + kind: 'design-spec', + mediaType: 'application/json', + localPath: 'assets/truncated-depth-0.json', + source: { + kind: 'canvas', + resourceId: 'external-truncated-depth-0', + }, + }, + { + id: 'truncated-depth-1', + kind: 'metadata', + mediaType: 'application/json', + localPath: 'assets/truncated-depth-1.json', + source: { + kind: 'canvas', + resourceId: 'external-truncated-depth-1', + referenceResourceIds: ['external-truncated-depth-0'], + }, + }, + { + id: 'truncated-depth-2', + kind: 'metadata', + mediaType: 'application/json', + localPath: 'assets/truncated-depth-2.json', + source: { + kind: 'canvas', + resourceId: 'external-truncated-depth-2', + referenceResourceIds: ['external-truncated-depth-1'], + }, + }, + ); + const slotWidth = + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP; + const existingPositions: ProjectResourceCanvasPosition[] = [ + { + resourceId: 'asset:truncated-depth-0', + section: 'document', + x: 0, + y: 0, + manuallyPlaced: false, + }, + { + resourceId: 'asset:truncated-depth-1', + section: 'document', + x: slotWidth, + y: 0, + manuallyPlaced: false, + }, + { + resourceId: 'asset:truncated-depth-2', + section: 'document', + x: slotWidth * 2, + y: 0, + manuallyPlaced: false, + }, + ]; + const layoutUpdates: ProjectResourceCanvasPosition[][] = []; + let layoutReads = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return { + resourceIds: existingPositions.map( + ({ resourceId }) => resourceId, + ), + referenceEdges: [ + { + id: 'reference:truncated-0-1', + kind: 'asset-reference', + sourceResourceId: 'asset:truncated-depth-0', + targetResourceId: 'asset:truncated-depth-1', + cyclic: false, + }, + { + id: 'reference:truncated-1-2', + kind: 'asset-reference', + sourceResourceId: 'asset:truncated-depth-1', + targetResourceId: 'asset:truncated-depth-2', + cyclic: false, + }, + ], + taskFlows: [ + { + id: 'flow:untrusted-producer', + kind: 'task-flow', + sourceTaskId: 'art-director', + targetTaskId: 'design-foundation', + sourceResourceIds: ['asset:truncated-depth-0'], + targetResourceIds: ['asset:truncated-depth-1'], + cyclic: true, + }, + ], + connectionIndex: [ + { + resourceId: 'asset:truncated-depth-0', + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: ['asset:truncated-depth-1'], + referenceEdgeIds: ['reference:truncated-0-1'], + taskFlowIds: ['flow:untrusted-producer'], + }, + { + resourceId: 'asset:truncated-depth-1', + upstreamReferenceResourceIds: ['asset:truncated-depth-0'], + downstreamReferenceResourceIds: ['asset:truncated-depth-2'], + referenceEdgeIds: [ + 'reference:truncated-0-1', + 'reference:truncated-1-2', + ], + taskFlowIds: ['flow:untrusted-producer'], + }, + { + resourceId: 'asset:truncated-depth-2', + upstreamReferenceResourceIds: ['asset:truncated-depth-1'], + downstreamReferenceResourceIds: [], + referenceEdgeIds: ['reference:truncated-1-2'], + taskFlowIds: [], + }, + ], + producerAssignments: [ + { + resourceId: 'asset:truncated-depth-0', + taskId: 'art-director', + }, + ], + dependencyDepths: [ + { + resourceId: 'asset:truncated-depth-0', + dependencyDepth: 0, + }, + { + resourceId: 'asset:truncated-depth-1', + dependencyDepth: 1, + }, + { + resourceId: 'asset:truncated-depth-2', + dependencyDepth: 2, + }, + ], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: ['art-director'], + producerMappingTruncated: true, + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + layoutReads += 1; + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: 7, + positions: structuredClone(existingPositions), + updatedAt: 7, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutUpdates.push( + structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ), + ); + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: 8, + positions: structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ), + updatedAt: 8, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: '截断依赖图布局保护测试', + projectPath, + manifest, + attachments: [], + agentResults: [], + supervisor: React.createElement('div', null, '项目总控'), + }), + ); + + await waitFor(() => expect(layoutReads).toBe(1)); + const cards = [0, 1, 2].map((depth) => + screen.getByRole('button', { + name: new RegExp(`truncated-depth-${depth}\\.json`, 'u'), + }), + ); + await waitFor(() => { + expect(cards[0]?.getAttribute('style')).toContain('--resource-x: 0px'); + expect(cards[1]?.getAttribute('style')).toContain( + `--resource-x: ${slotWidth}px`, + ); + expect(cards[2]?.getAttribute('style')).toContain( + `--resource-x: ${slotWidth * 2}px`, + ); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(layoutUpdates).toEqual([]); + }); + + it('restores historical resource positions but never moves or persists them from pointer input', async () => { const projectId = 'workbench-layout-persistence'; const projectPath = '/tmp/workbench-layout-persistence'; const resourceId = 'agent-result:design-foundation:layout-result-run'; @@ -763,7 +1908,7 @@ export function registerProjectWorkbenchFoundationTests() { updatedAt: 1, }, ]; - let persistedLayout: ProjectResourceCanvasLayout = { + const persistedLayout: ProjectResourceCanvasLayout = { schemaVersion: 'game-creator-resource-layout.v1', projectId, mode: 'dependency', @@ -781,25 +1926,14 @@ export function registerProjectWorkbenchFoundationTests() { }; const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } if (command === 'read_local_project_resource_canvas_layout') { return structuredClone(persistedLayout); } if (command === 'update_local_project_resource_canvas_layout') { - const input = args as { - expectedRevision: number; - positions: ProjectResourceCanvasPosition[]; - }; - expect(input.expectedRevision).toBe(persistedLayout.revision); - persistedLayout = { - ...persistedLayout, - revision: persistedLayout.revision + 1, - positions: structuredClone(input.positions), - updatedAt: persistedLayout.updatedAt + 1, - }; - return { - status: 'updated', - layout: structuredClone(persistedLayout), - }; + throw new Error('pointer input must not submit a manual layout CAS'); } throw new Error(`unexpected invoke ${command}`); }, @@ -820,12 +1954,16 @@ export function registerProjectWorkbenchFoundationTests() { } renderWorkbench(); - const card = screen.getByText('布局持久化回执').closest('button'); + const card = (await screen.findByText('布局持久化回执')).closest('button'); expect(card).not.toBeNull(); await waitFor(() => { expect(card?.getAttribute('style')).toContain('--resource-x: 12px'); expect(card?.getAttribute('style')).toContain('--resource-y: 24px'); }); + const originalStyle = card?.getAttribute('style'); + const layoutUpdatesBeforePointer = invoke.mock.calls.filter( + ([command]) => command === 'update_local_project_resource_canvas_layout', + ).length; fireEvent.pointerDown(card!, { pointerId: 11, @@ -843,247 +1981,34 @@ export function registerProjectWorkbenchFoundationTests() { clientX: 100, clientY: 60, }); - - await waitFor(() => { - expect(persistedLayout.revision).toBe(2); - expect(persistedLayout.positions).toContainEqual({ - resourceId, - section: 'document', - x: 92, - y: 54, - manuallyPlaced: true, - }); - }); - expect(invoke).toHaveBeenCalledWith( - 'update_local_project_resource_canvas_layout', - expect.objectContaining({ - projectPath, - mode: 'dependency', - expectedRevision: 1, - }), - ); - - cleanup(); - renderWorkbench(); - const restoredCard = screen.getByText('布局持久化回执').closest('button'); - await waitFor(() => { - expect(restoredCard?.getAttribute('style')).toContain( - '--resource-x: 92px', - ); - expect(restoredCard?.getAttribute('style')).toContain( - '--resource-y: 54px', - ); - }); - }); - - it('loads the latest layout after a CAS conflict without replaying the drag', async () => { - const projectId = 'workbench-layout-conflict'; - const projectPath = '/tmp/workbench-layout-conflict'; - const resourceId = 'agent-result:design-foundation:conflict-result-run'; - const manifest = createGameCreationAppManifest(projectId, '布局冲突测试'); - const latestLayout: ProjectResourceCanvasLayout = { - schemaVersion: 'game-creator-resource-layout.v1', - projectId, - mode: 'dependency', - revision: 4, - positions: [ - { - resourceId, - section: 'document', - x: 400, - y: 80, - manuallyPlaced: true, - }, - ], - updatedAt: 400, - }; - const invoke = vi.fn(async (command: string) => { - if (command === 'read_local_project_resource_canvas_layout') { - return { - ...structuredClone(latestLayout), - revision: 3, - positions: [ - { - ...latestLayout.positions[0], - x: 20, - y: 30, - }, - ], - }; - } - if (command === 'update_local_project_resource_canvas_layout') { - return { status: 'conflict', layout: structuredClone(latestLayout) }; - } - throw new Error(`unexpected invoke ${command}`); - }); - window.__TAURI__ = { core: { invoke } }; - - render( - React.createElement(ProjectDevelopmentView, { - projectName: '布局冲突测试', - projectPath, - manifest, - attachments: [], - agentResults: [ - { - agentId: 'design-foundation', - runId: 'conflict-result-run', - label: '玩法策划 Agent', - title: '布局冲突回执', - content: '布局冲突正文', - updatedAt: 1, - }, - ], - supervisor: React.createElement('div', null, '项目总控'), - }), - ); - const card = screen.getByText('布局冲突回执').closest('button'); - await waitFor(() => { - expect(card?.getAttribute('style')).toContain('--resource-x: 20px'); - }); - - fireEvent.pointerDown(card!, { - pointerId: 12, - button: 0, - clientX: 0, - clientY: 0, - }); - fireEvent.pointerMove(card!, { - pointerId: 12, - clientX: 200, - clientY: 40, - }); - fireEvent.pointerUp(card!, { - pointerId: 12, - clientX: 200, - clientY: 40, - }); - - expect( - await screen.findByText('布局已在其他窗口更新,请重新拖动'), - ).not.toBeNull(); - expect(card?.getAttribute('style')).toContain('--resource-x: 400px'); - expect(card?.getAttribute('style')).toContain('--resource-y: 80px'); + fireEvent.pointerCancel(card!, { pointerId: 11 }); + expect(card?.getAttribute('style')).toBe(originalStyle); + expect(card?.classList.contains('is-dragging')).toBe(false); expect( invoke.mock.calls.filter( ([command]) => command === 'update_local_project_resource_canvas_layout', ), - ).toHaveLength(1); - }); - - it('drops manual writes queued behind a CAS conflict so stale coordinates cannot overwrite the winner', async () => { - const projectId = 'workbench-layout-queued-conflict'; - const projectPath = '/tmp/workbench-layout-queued-conflict'; - const resourceId = 'agent-result:design-foundation:queued-conflict-run'; - const manifest = createGameCreationAppManifest( - projectId, - '布局排队冲突测试', - ); - const latestLayout: ProjectResourceCanvasLayout = { - schemaVersion: 'game-creator-resource-layout.v1', - projectId, - mode: 'dependency', - revision: 2, - positions: [ - { - resourceId, - section: 'document', - x: 400, - y: 80, - manuallyPlaced: true, - }, - ], - updatedAt: 200, - }; - let resolveFirstUpdate: - | ((result: { - status: 'conflict'; - layout: ProjectResourceCanvasLayout; - }) => void) - | null = null; - let updateCalls = 0; - const invoke = vi.fn(async (command: string) => { - if (command === 'read_local_project_resource_canvas_layout') { - return { - ...structuredClone(latestLayout), - revision: 1, - positions: [{ ...latestLayout.positions[0], x: 20, y: 30 }], - }; - } - if (command === 'update_local_project_resource_canvas_layout') { - updateCalls += 1; - return await new Promise((resolve) => { - resolveFirstUpdate = resolve; - }); - } - throw new Error(`unexpected invoke ${command}`); - }); - window.__TAURI__ = { core: { invoke } }; - - render( - React.createElement(ProjectDevelopmentView, { - projectName: '布局排队冲突测试', - projectPath, - manifest, - attachments: [], - agentResults: [ - { - agentId: 'design-foundation', - runId: 'queued-conflict-run', - label: '玩法策划 Agent', - title: '排队冲突回执', - content: '排队冲突正文', - updatedAt: 1, - }, - ], - supervisor: React.createElement('div', null, '项目总控'), - }), - ); - const card = screen.getByText('排队冲突回执').closest('button'); - await waitFor(() => { - expect(card?.getAttribute('style')).toContain('--resource-x: 20px'); - }); - - for (const [pointerId, startX, endX] of [ - [31, 0, 100], - [32, 100, 220], - ] as const) { - fireEvent.pointerDown(card!, { - pointerId, - button: 0, - clientX: startX, - clientY: 0, - }); - fireEvent.pointerMove(card!, { - pointerId, - clientX: endX, - clientY: 20, - }); - fireEvent.pointerUp(card!, { - pointerId, - clientX: endX, - clientY: 20, - }); - } - expect(updateCalls).toBe(1); - - await act(async () => { - resolveFirstUpdate?.({ - status: 'conflict', - layout: structuredClone(latestLayout), - }); - await Promise.resolve(); - }); + ).toHaveLength(layoutUpdatesBeforePointer); + fireEvent.click(card!); expect( - await screen.findByText('布局已在其他窗口更新,请重新拖动'), + screen.getByRole('region', { name: '布局持久化回执' }), ).not.toBeNull(); + + cleanup(); + renderWorkbench(); + const restoredCard = (await screen.findByText('布局持久化回执')).closest( + 'button', + ); await waitFor(() => { - expect(card?.getAttribute('style')).toContain('--resource-x: 400px'); - expect(card?.getAttribute('style')).toContain('--resource-y: 80px'); + expect(restoredCard?.getAttribute('style')).toContain( + '--resource-x: 12px', + ); + expect(restoredCard?.getAttribute('style')).toContain( + '--resource-y: 24px', + ); }); - expect(updateCalls).toBe(1); }); it('maps manifest asset kinds into stable type layout ordering', async () => { @@ -1114,6 +2039,9 @@ export function registerProjectWorkbenchFoundationTests() { }> = []; const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } const mode = args?.mode as 'dependency' | 'type'; if (command === 'read_local_project_resource_canvas_layout') { return { @@ -1187,22 +2115,27 @@ export function registerProjectWorkbenchFoundationTests() { it('keeps newly reconciled resources visible when their automatic layout save fails', async () => { const projectId = 'workbench-layout-save-failure'; const manifest = createGameCreationAppManifest(projectId, '布局失败测试'); - const invoke = vi.fn(async (command: string) => { - if (command === 'read_local_project_resource_canvas_layout') { - return { - schemaVersion: 'game-creator-resource-layout.v1', - projectId, - mode: 'dependency', - revision: 2, - positions: [], - updatedAt: 200, - }; - } - if (command === 'update_local_project_resource_canvas_layout') { - throw new Error('disk full'); - } - throw new Error(`unexpected invoke ${command}`); - }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: 'dependency', + revision: 2, + positions: [], + updatedAt: 200, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + throw new Error('disk full'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); window.__TAURI__ = { core: { invoke } }; render( @@ -1352,7 +2285,7 @@ export function registerProjectWorkbenchFoundationTests() { target: { value: 'hero.png' }, }); fireEvent.click(screen.getByRole('button', { name: /hero\.png/ })); - expect(screen.getByLabelText('资源焦点')).not.toBeNull(); + expect(screen.getByRole('region', { name: 'hero.png' })).not.toBeNull(); expect(screen.getAllByText('assets/hero.png').length).toBeGreaterThan(0); const previewImage = await screen.findByRole('img', { name: 'hero.png 图片预览', @@ -6807,7 +7740,7 @@ export function registerProjectSupervisorSurfaceTests() { return card; }); fireEvent.click(updatedDesignCard); - const updatedDesignDialog = await screen.findByRole('dialog', { + const updatedDesignDialog = await screen.findByRole('region', { name: '策划 Agent 文本回执', }); expect( diff --git a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts new file mode 100644 index 000000000..7f98d4175 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; + +import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel'; + +describe('项目资源投影', () => { + it('只把明确资源投影到固定分类,未知任务产物不会伪装成项目版本', () => { + const manifest = createGameCreationAppManifest( + 'resource-projection', + '资源投影测试', + ); + const designTask = manifest.tasks.find( + (task) => task.id === 'design-foundation', + ); + const audioTask = manifest.tasks.find( + (task) => task.id === 'audio-asset-plan', + ); + if (!designTask || !audioTask) { + throw new Error('缺少资源投影测试任务'); + } + designTask.status = 'completed'; + designTask.artifacts = [ + 'memory/game-design.md', + 'assets/ui-preview.svg', + 'build/game.bundle', + ]; + audioTask.status = 'completed'; + audioTask.artifacts = ['audio/unregistered-bgm.wav']; + manifest.assets = [ + { + id: 'registered-bgm', + kind: 'bgm', + mediaType: 'audio/wav', + localPath: 'audio/registered-bgm.wav', + source: { kind: 'generated' }, + }, + { + id: 'character-animation', + kind: 'character-animation', + mediaType: 'application/json', + localPath: 'assets/character-animation.json', + source: { kind: 'generated' }, + }, + { + id: 'unknown-binary', + kind: 'binary', + mediaType: 'application/octet-stream', + localPath: 'build/game.bundle', + source: { kind: 'generated' }, + }, + ]; + manifest.versions = [ + { + versionId: 'version-1', + parentVersionId: null, + projectRevision: 7, + resourceBindings: [ + { slotId: 'background-music', resourceId: 'registered-bgm' }, + ], + createdReason: 'initial', + createdAt: 1, + }, + ]; + + const resources = projectResourcesFromReadModels( + manifest, + [ + { + fileName: 'rules.yaml', + mediaType: 'application/yaml', + localPath: 'imports/rules.yaml', + status: 'imported', + }, + { + fileName: 'voice.ogg', + mediaType: 'audio/ogg', + localPath: 'imports/voice.ogg', + status: 'imported', + }, + { + fileName: 'archive.zip', + mediaType: 'application/zip', + localPath: 'imports/archive.zip', + status: 'imported', + }, + ], + [ + { + agentId: 'design-foundation', + runId: 'final-run', + label: '玩法策划 Agent', + title: '玩法文档回执', + content: '回执正文', + updatedAt: 1, + }, + ], + ); + + expect(resources.map(({ id, category }) => ({ id, category }))).toEqual( + expect.arrayContaining([ + { + id: 'task:design-foundation:memory/game-design.md', + category: 'document', + }, + { + id: 'task:design-foundation:assets/ui-preview.svg', + category: 'art', + }, + { id: 'asset:registered-bgm', category: 'audio' }, + { id: 'asset:character-animation', category: 'art' }, + { id: 'attachment:imports/rules.yaml', category: 'document' }, + { id: 'attachment:imports/voice.ogg', category: 'audio' }, + { + id: 'agent-result:design-foundation:final-run', + category: 'document', + }, + { id: 'version:version-1', category: 'version' }, + ]), + ); + expect( + resources.some(({ id }) => + [ + 'task:design-foundation:build/game.bundle', + 'task:audio-asset-plan:audio/unregistered-bgm.wav', + 'asset:unknown-binary', + 'attachment:imports/archive.zip', + ].includes(id), + ), + ).toBe(false); + expect( + resources.filter(({ category }) => category === 'version'), + ).toHaveLength(1); + }); + + it('显示名称变化不会改变正式资源身份', () => { + const manifest = createGameCreationAppManifest( + 'stable-resource-id', + '稳定资源身份测试', + ); + manifest.versions = [ + { + versionId: 'stable-version', + parentVersionId: null, + projectRevision: 1, + resourceBindings: [], + createdReason: 'initial', + createdAt: 1, + }, + ]; + const first = projectResourcesFromReadModels( + manifest, + [], + [ + { + agentId: 'design-foundation', + runId: 'stable-run', + label: '策划 Agent', + title: '旧标题', + content: '正文', + updatedAt: 1, + }, + ], + ); + const second = projectResourcesFromReadModels( + manifest, + [], + [ + { + agentId: 'design-foundation', + runId: 'stable-run', + label: '策划 Agent', + title: '新标题', + content: '正文', + updatedAt: 2, + }, + ], + ); + + expect(first.map(({ id }) => id)).toEqual(second.map(({ id }) => id)); + }); + + it('只从 manifest 投影版本并保留直接父子关系', () => { + const manifest = createGameCreationAppManifest( + 'version-projection', + '版本投影测试', + ); + manifest.versions = [ + { + versionId: 'version-root', + parentVersionId: null, + projectRevision: 2, + resourceBindings: [], + createdReason: 'initial', + createdAt: 100, + }, + { + versionId: 'version-child', + parentVersionId: 'version-root', + projectRevision: 3, + resourceBindings: [], + createdReason: 'agent-revision', + createdAt: 200, + }, + ]; + + const versions = projectResourcesFromReadModels(manifest, [], []).filter( + (resource) => resource.category === 'version', + ); + + expect(versions.map((version) => version.label)).toEqual([ + '版本 1', + '版本 2', + ]); + expect(versions[0]?.version?.childVersionIds).toEqual(['version-child']); + expect(versions[1]?.version?.parentVersionId).toBe('version-root'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts new file mode 100644 index 000000000..9fdc7a1f7 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizeProjectResourceGraph, + projectResourceGraphNeighbors, + type ProjectResourceGraphReadModel, +} from '../src/view/project-development/resourceDependencyGraphModel'; + +function readModel( + overrides: Partial = {}, +): ProjectResourceGraphReadModel { + return { + resourceIds: [], + referenceEdges: [], + taskFlows: [], + connectionIndex: [], + producerAssignments: [], + dependencyDepths: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + ...overrides, + }; +} + +describe('resource dependency graph model', () => { + it('normalizes the Rust read model and filters stale resource endpoints', () => { + const graph = normalizeProjectResourceGraph( + readModel({ + resourceIds: ['asset:source', 'asset:target'], + referenceEdges: [ + { + id: 'valid-reference', + kind: 'asset-reference', + sourceResourceId: 'asset:source', + targetResourceId: 'asset:target', + cyclic: false, + }, + { + id: 'ghost-reference', + kind: 'asset-reference', + sourceResourceId: 'asset:deleted', + targetResourceId: 'asset:target', + cyclic: false, + }, + ], + taskFlows: [ + { + id: 'valid-flow', + kind: 'task-flow', + sourceTaskId: 'source-task', + targetTaskId: 'target-task', + sourceResourceIds: ['asset:source', 'asset:deleted'], + targetResourceIds: ['asset:target'], + cyclic: false, + }, + { + id: 'ghost-flow', + kind: 'task-flow', + sourceTaskId: 'deleted-task', + targetTaskId: 'target-task', + sourceResourceIds: ['asset:deleted'], + targetResourceIds: ['asset:target'], + cyclic: false, + }, + ], + cyclicResourceIds: ['asset:source', 'asset:deleted'], + }), + ); + + expect(graph.referenceEdges.map((edge) => edge.id)).toEqual([ + 'valid-reference', + ]); + expect(graph.taskFlows).toEqual([ + expect.objectContaining({ + id: 'valid-flow', + sourceResourceIds: ['asset:source'], + targetResourceIds: ['asset:target'], + }), + ]); + expect(graph.cyclicResourceIds).toEqual(new Set(['asset:source'])); + }); + + it('queries direct reference and aggregated task-flow neighbors from the bounded index', () => { + const graph = normalizeProjectResourceGraph( + readModel({ + resourceIds: ['source:a', 'source:b', 'target:a', 'target:b'], + referenceEdges: [ + { + id: 'reference:a', + kind: 'asset-reference', + sourceResourceId: 'source:a', + targetResourceId: 'target:a', + cyclic: false, + }, + ], + taskFlows: [ + { + id: 'flow:a-b', + kind: 'task-flow', + sourceTaskId: 'task:a', + targetTaskId: 'task:b', + sourceResourceIds: ['source:a', 'source:b'], + targetResourceIds: ['target:a', 'target:b'], + cyclic: false, + }, + ], + connectionIndex: [ + { + resourceId: 'target:a', + upstreamReferenceResourceIds: ['source:a'], + downstreamReferenceResourceIds: [], + referenceEdgeIds: ['reference:a'], + taskFlowIds: ['flow:a-b'], + }, + ], + }), + ); + + const neighbors = projectResourceGraphNeighbors(graph, 'target:a'); + expect(neighbors.upstreamResourceIds).toEqual( + new Set(['source:a', 'source:b']), + ); + expect(neighbors.downstreamResourceIds).toEqual(new Set()); + expect(neighbors.connectedEdgeIds).toEqual( + new Set(['reference:a', 'flow:a-b']), + ); + }); + + it('fails closed only for producer-derived data when the audit tail is truncated', () => { + const graph = normalizeProjectResourceGraph( + readModel({ + resourceIds: [ + 'asset:spec', + 'asset:ui', + 'asset:derived', + 'asset:negative', + 'asset:fractional', + 'asset:unsafe', + ], + producerAssignments: [ + { + resourceId: 'asset:spec', + taskId: 'art-director', + }, + { + resourceId: 'asset:ui', + taskId: 'design-foundation', + }, + { + resourceId: 'asset:deleted', + taskId: 'task-1', + }, + ], + dependencyDepths: [ + { + resourceId: 'asset:spec', + dependencyDepth: 0, + }, + { + resourceId: 'asset:ui', + dependencyDepth: 1, + }, + { + resourceId: 'asset:deleted', + dependencyDepth: 99, + }, + { + resourceId: 'asset:ui', + dependencyDepth: 0, + }, + { + resourceId: 'asset:derived', + dependencyDepth: 2, + }, + { + resourceId: 'asset:negative', + dependencyDepth: -1, + }, + { + resourceId: 'asset:fractional', + dependencyDepth: 1.5, + }, + { + resourceId: 'asset:unsafe', + dependencyDepth: Number.MAX_SAFE_INTEGER + 1, + }, + ], + taskFlows: [ + { + id: 'flow:spec-ui', + kind: 'task-flow', + sourceTaskId: 'art-director', + targetTaskId: 'design-foundation', + sourceResourceIds: ['asset:spec'], + targetResourceIds: ['asset:ui'], + cyclic: false, + }, + ], + referenceEdges: [ + { + id: 'reference:spec-ui', + kind: 'asset-reference', + sourceResourceId: 'asset:spec', + targetResourceId: 'asset:ui', + cyclic: false, + }, + ], + connectionIndex: [ + { + resourceId: 'asset:spec', + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: ['asset:ui'], + referenceEdgeIds: ['reference:spec-ui'], + taskFlowIds: ['flow:spec-ui'], + }, + { + resourceId: 'asset:ui', + upstreamReferenceResourceIds: ['asset:spec'], + downstreamReferenceResourceIds: [], + referenceEdgeIds: ['reference:spec-ui'], + taskFlowIds: ['flow:spec-ui'], + }, + ], + unresolvedReferenceResourceIds: ['external:missing'], + cyclicResourceIds: ['asset:ui'], + cyclicTaskIds: ['art-director'], + producerMappingTruncated: true, + }), + ); + + expect(graph.producerTaskIdByResourceId).toEqual(new Map()); + expect(graph.dependencyDepthByResourceId).toEqual( + new Map([ + ['asset:spec', 0], + ['asset:ui', 1], + ['asset:derived', 2], + ]), + ); + expect(graph.taskFlows).toEqual([]); + expect(graph.cyclicTaskIds).toEqual(new Set()); + expect(graph.referenceEdges.map((edge) => edge.id)).toEqual([ + 'reference:spec-ui', + ]); + expect(graph.unresolvedReferenceResourceIds).toEqual([ + 'external:missing', + ]); + expect(graph.cyclicResourceIds).toEqual(new Set(['asset:ui'])); + expect(graph.producerMappingTruncated).toBe(true); + expect(projectResourceGraphNeighbors(graph, 'asset:ui')).toEqual({ + upstreamResourceIds: new Set(['asset:spec']), + downstreamResourceIds: new Set(), + connectedEdgeIds: new Set(['reference:spec-ui']), + }); + expect(projectResourceGraphNeighbors(graph, 'asset:deleted')).toEqual({ + upstreamResourceIds: new Set(), + downstreamResourceIds: new Set(), + connectedEdgeIds: new Set(), + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index d07892f34..1b4cf3e2c 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -58,6 +58,17 @@ function position( }; } +function automaticPosition( + resourceId: string, + x: number, + y: number, +): ProjectResourceCanvasPosition { + return { + ...position(resourceId, x, y), + manuallyPlaced: false, + }; +} + afterEach(() => { cleanup(); window.__TAURI__ = undefined; @@ -72,6 +83,178 @@ describe('useProjectResourceCanvasLayout', () => { ); }); + it('waits for dependency graph initialization before reading or writing layout', async () => { + const updates: ProjectResourceCanvasPosition[][] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 0, []); + } + if (command === 'update_local_project_resource_canvas_layout') { + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + updates.push(positions); + return { + status: 'updated', + layout: persistedLayout('dependency', 1, positions), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const shallowResource = resource('resource-a'); + const deepResource = { ...shallowResource, dependencyDepth: 2 }; + const { result, rerender } = renderHook( + ({ initializationReady, resources }) => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources, + initializationReady, + rederiveAutomaticPositions: true, + }), + { + initialProps: { + initializationReady: false, + resources: [shallowResource], + }, + }, + ); + + expect(invoke).not.toHaveBeenCalled(); + expect(result.current.layout.positions).toEqual([]); + + rerender({ initializationReady: true, resources: [deepResource] }); + await waitFor(() => expect(updates).toHaveLength(1)); + expect(result.current.layout.positions[0]).toMatchObject({ + resourceId: 'resource-a', + x: 392, + manuallyPlaced: false, + }); + expect(invoke.mock.calls[0]?.[0]).toBe( + 'read_local_project_resource_canvas_layout', + ); + }); + + it('persists distinct automatic columns for dependency depths 0, 1, and 2', async () => { + const updates: ProjectResourceCanvasPosition[][] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 0, []); + } + if (command === 'update_local_project_resource_canvas_layout') { + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + updates.push(positions); + return { + status: 'updated', + layout: persistedLayout('dependency', 1, positions), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [ + resource('resource-depth-0'), + { ...resource('resource-depth-1'), dependencyDepth: 1 }, + { ...resource('resource-depth-2'), dependencyDepth: 2 }, + ], + rederiveAutomaticPositions: true, + }), + ); + + await waitFor(() => expect(updates).toHaveLength(1)); + expect(updates[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-depth-0', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-depth-1', + x: 196, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-depth-2', + x: 392, + y: 0, + manuallyPlaced: false, + }), + ]), + ); + }); + + it('rederives automatic dependency positions while preserving manual positions', async () => { + const resourceA = { ...resource('resource-a'), dependencyDepth: 2 }; + const resourceB = resource('resource-b'); + const updates: ProjectResourceCanvasPosition[][] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 4, [ + automaticPosition('resource-a', 0, 0), + position('resource-b', 600, 40), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + updates.push(positions); + return { + status: 'updated', + layout: persistedLayout('dependency', 5, positions), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resourceA, resourceB], + rederiveAutomaticPositions: true, + }), + ); + + await waitFor(() => expect(updates).toHaveLength(1)); + expect(result.current.layout.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-a', + x: 392, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-b', + x: 600, + y: 40, + manuallyPlaced: true, + }), + ]), + ); + }); + it('rejects an unsafe revision from the initial IPC read without writing', async () => { const resourceA = resource('resource-a'); const invoke = vi.fn(async (command: string) => { @@ -316,7 +499,7 @@ describe('useProjectResourceCanvasLayout', () => { ).toBe(true); }); - it('coalesces repeated queued drags for the same resource behind an in-flight CAS', async () => { + it('coalesces repeated queued manual placements for the same resource behind an in-flight CAS', async () => { const resourceA = resource('resource-a'); const updates: Array<{ expectedRevision: number; @@ -455,11 +638,11 @@ describe('useProjectResourceCanvasLayout', () => { }); expect(result.current.saving).toBe(false); }); - expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动'); + expect(result.current.notice).toBe('布局已在其他窗口更新'); expect(updateCalls).toBe(1); }); - it('keeps the redrag notice when resource reconciliation drops a queued manual intent and retries', async () => { + it('keeps the manual conflict notice when resource reconciliation drops a queued manual intent and retries', async () => { const resourceA = resource('resource-a'); const resourceB = resource('resource-b'); const updates: Array<{ @@ -553,7 +736,7 @@ describe('useProjectResourceCanvasLayout', () => { expect.objectContaining({ resourceId: 'resource-a', x: 400, y: 80 }), ]), ); - expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动'); + expect(result.current.notice).toBe('布局已在其他窗口更新'); expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 2400)).toBe( false, ); @@ -736,7 +919,7 @@ describe('useProjectResourceCanvasLayout', () => { await waitFor(() => expect(expectedRevisions).toHaveLength(3)); await waitFor(() => expect(result.current.saving).toBe(false)); expect(expectedRevisions).toEqual([1, 2, 3]); - expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动'); + expect(result.current.notice).toBe('布局已在其他窗口更新'); expect( result.current.layout.positions.some( ({ resourceId }) => resourceId === 'resource-b', diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 26ca9cc53..0765ae9cc 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -733,7 +733,14 @@ for (const snippet of [ 'header[3] === 0x46', '(stat.mode & 0o111) === 0', 'header.readUInt32BE(0)', + 'machMagic === 0xcafebabe', + 'machMagic === 0xbebafeca', + 'machMagic === 0xcafebabf', + 'machMagic === 0xbfbafeca', + 'machMagic === 0xfeedface', + 'machMagic === 0xcefaedfe', 'machMagic === 0xfeedfacf', + 'machMagic === 0xcffaedfe', 'header[0] !== 0x4d', 'header[1] !== 0x5a', "console.log('[check:native-shells] desktop-release-binary-artifact')", @@ -755,6 +762,15 @@ for (const snippet of [ "'desktop'", 'fs.copyFileSync(sourcePath, stagedPath)', 'fs.chmodSync(stagedPath, sourceMode & 0o777)', + 'header.readUInt32BE(0)', + 'machMagic === 0xcafebabe', + 'machMagic === 0xbebafeca', + 'machMagic === 0xcafebabf', + 'machMagic === 0xbfbafeca', + 'machMagic === 0xfeedface', + 'machMagic === 0xcefaedfe', + 'machMagic === 0xfeedfacf', + 'machMagic === 0xcffaedfe', "console.log(`[desktop-shell:stage-release-binary] ${stagedPath}`)", ]) { if (!stageReleaseBinarySource.includes(snippet)) { diff --git a/apps/desktop-shell/scripts/stage-release-binary.mjs b/apps/desktop-shell/scripts/stage-release-binary.mjs index 13ce5bac8..44cbc143a 100644 --- a/apps/desktop-shell/scripts/stage-release-binary.mjs +++ b/apps/desktop-shell/scripts/stage-release-binary.mjs @@ -49,9 +49,13 @@ function assertExecutable(filePath, label) { const machMagic = header.readUInt32BE(0); const isMachO = machMagic === 0xcafebabe || - machMagic === 0xcafed00d || + machMagic === 0xbebafeca || + machMagic === 0xcafebabf || + machMagic === 0xbfbafeca || machMagic === 0xfeedface || - machMagic === 0xfeedfacf; + machMagic === 0xcefaedfe || + machMagic === 0xfeedfacf || + machMagic === 0xcffaedfe; if (!isMachO || (stat.mode & 0o111) === 0) { throw new Error(`${label} must be an executable Mach-O file`); } diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 11a3771c3..7b14b392d 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -1,6 +1,6 @@ # AI 游戏创作项目开发工作台 PRD -更新时间:`2026-07-28` +更新时间:`2026-08-05`(实时 manifest 与资源焦点状态机收口) ## 1. 产品定位 @@ -43,10 +43,12 @@ ### 3.3 资源布局 - “按依赖”和“按类型”分别保存画布位置。 -- 切换布局模式后恢复该模式最后一次用户手动拖动结果。 +- 当前只允许 dependency / type 自动布局与资源卡点击,不提供资源卡手动拖动入口。 +- 切换布局模式后恢复该模式 sidecar 中已有坐标;历史手动坐标只读恢复,不删除、不重置、不迁移。 - 新资源首次进入某个布局时才执行默认不重叠排版;已有坐标不得被自动排序覆盖。 - 依赖布局使用资源生成/引用关系;类型布局按资源大类、子类型、尺寸规格排序。 -- 不同资源分区不可互相拖入。 +- 资源分区由资源投影固定,前端交互不能改变分类。 +- 资源卡手动拖动、拖动持久化、拖动性能与冲突后的重新拖动提示全部暂缓,不作为当前产品合同或验收条件。 ### 3.4 数值微调 @@ -58,14 +60,14 @@ 现有六个专业组为: -| group | 普通用户名称 | 当前职责 | -| --- | --- | --- | -| `design` | 策划组 | 玩法规格、界面原型、规则与验收口径 | -| `art` | 美术组 | 角色、场景、UI、动画和美术素材 | -| `code` | 程序组 | 可运行原型、模块实现和工程验证 | -| `balance` | 数值组 | 速度、生命、得分和难度参数 | -| `audio` | 音频组 | 背景音乐、音效和音频资源 | -| `publishing` | 发布组 | 质量评审、试玩、打包和发布准备 | +| group | 普通用户名称 | 当前职责 | +| ------------ | ------------ | ---------------------------------- | +| `design` | 策划组 | 玩法规格、界面原型、规则与验收口径 | +| `art` | 美术组 | 角色、场景、UI、动画和美术素材 | +| `code` | 程序组 | 可运行原型、模块实现和工程验证 | +| `balance` | 数值组 | 速度、生命、得分和难度参数 | +| `audio` | 音频组 | 背景音乐、音效和音频资源 | +| `publishing` | 发布组 | 质量评审、试玩、打包和发布准备 | - 底栏默认突出策划、美术、程序三组。 - 允许在同一底栏展开数值、音频、发布组,不删除既有专业组。 @@ -121,9 +123,15 @@ completed -> starting(nextSlice) idle -> focused(document|art|audio|version) -> idle ``` -- 文档:在中央画布展开并独立滚动。 -- 美术/音频:进入对应媒体聚焦状态,工具能力复用现有编辑器。 -- 版本:高亮版本引用资源;替换动作只创建下一迭代版本。 +- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。 +- 美术:PNG、JPEG、WEBP 继续使用图片魔数与像素边界预览;GIF、SVG、AVIF、BMP、MP4、WebM、MOV 通过新增受控媒体读取链路按文件签名校验后在中央画布放大聚焦。SVG 额外拒绝脚本、事件处理器、外部资源引用和实体声明;视频使用内置播放控件。读取失败显示错误空态。 +- 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。 +- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片。 +- mentor 最新决定:资源聚焦不提供工具栏,也不提供工具侧边栏。 +- 点击资源后,中央主视窗从 `resources.list` 切换为 `resources.focused.document / art / audio / version`,左侧平台导航、右侧 Supervisor 对话和底部 Agent 状态栏保持原位;聚焦容器只包含标题、资源主体、必要元数据与右上角收起按钮,不使用页面级浮层或可拖动标题栏。 +- 焦点转换以稳定资源 ID 为准。只有从资源列表进入详情或从一个资源 ID 切换到另一个 ID 时聚焦详情 region;同一资源 ID 因 manifest 更新而重新投影时,不得抢走详情内音频 / 视频控件、文档链接或收起按钮的当前焦点。 +- 显式收起或按 Escape 后恢复进入前的搜索条件、dependency / type 布局模式、资源画布滚动位置和选中资源,并优先把键盘焦点还给原触发资源卡;这些只属于当前前端会话,不写入布局 sidecar。若资源已经被后台删除,必须清理 stale focused / selected ID、关闭详情并把焦点落到“搜索项目资源”,不得落到 `body`。项目切换和进入运行视图必须取消旧项目的焦点恢复意图。 +- 阶段四只新增上述受控读取与媒体展示;阶段六在同一聚焦容器内补齐正式版本只读展示和引用高亮,但不新增资源聚焦工具栏 / 工具侧边栏,不新增美术编辑、音频编辑 / 替换、资源重新生成、版本替换或运行模块。飞书原需求中“编辑并生成新资源”的条件项仍暂缓,不能只打开画板却缺少回写、`referenceResourceIds` 血缘登记、新资源自动选中与邻近布局的完整闭环。 ### 4.4 历史成果与当前状态 @@ -153,7 +161,7 @@ P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可 ### 5.2 资源画布布局(P1) -实现状态(2026-07-28):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;关系线、资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。 +实现状态(2026-08-03):dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑与确定性依赖深度、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。依赖图加载完成前设布局初始化屏障,避免以临时 `dependencyDepth=0` 生成并持久化错误坐标。当前用户入口只允许自动布局与资源卡点击;资源卡手动拖动已按 mentor 决定暂缓。历史 sidecar 坐标继续只读恢复,底层布局读写与 CAS 合同保留,但当前没有用户手动布局入口。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。 ```ts type ProjectResourceCanvasLayout = { @@ -179,7 +187,7 @@ type ProjectResourceCanvasLayout = { - `x / y` 是相对所属 `section` 内容原点的 CSS 像素坐标,落盘前四舍五入为非负整数;坐标不使用 viewport、页面或资源详情浮层坐标系。 - `updatedAt` 是持久层生成的 Unix 毫秒时间戳,前端不得自行覆盖。 - `revision` 从 `0` 开始;布局文件不存在时读取接口合成 `revision=0 / positions=[]`,首次成功写入返回 `revision=1`,后续每次成功 CAS 写入递增 `1`。JSON / Tauri / TypeScript 全链路合法范围固定为 `0..=9_007_199_254_740_991`(`Number.MAX_SAFE_INTEGER`),读取、返回或提交负数、小数、非有限值与超限整数都必须失败关闭。 -- 新资源第一次进入某个 mode 时由默认布局写入 `manuallyPlaced=false`;用户完成一次有效拖动后写为 `true`。 +- 新资源第一次进入某个 mode 时由默认布局写入 `manuallyPlaced=false`。`manuallyPlaced=true` 仅用于兼容历史 sidecar 和保留底层合同;当前界面不会因指针操作新增该值。 - 同一份布局中 `resourceId` 必须唯一。持久层允许暂时存在当前资源投影中没有的旧 ID,因为 Agent 文本成果等资源可能晚于 manifest 恢复;前端协调后必须在下一次成功写入中清除已确认失效的坐标。 - 单份布局最多保存 `4096` 个位置,序列化文件不得超过 `2 MiB`;`resourceId` 最多 `512` 个 Unicode 字符,`x / y` 取值范围固定为 `0..=1_000_000`。 @@ -234,21 +242,46 @@ type UpdateProjectResourceCanvasLayoutResult = #### 5.2.4 前端布局与协调合同 -- 资源卡改用 Pointer Events 驱动二维拖动;超过统一移动阈值后才进入拖动态,普通点击仍打开唯一资源详情浮层,`pointercancel` 恢复拖动前位置。 -- 资源只能在原 `section` 内拖动。不同 section 之间既不能通过指针拖入,也不能通过持久 payload 改变当前资源的前端分类事实。 -- dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序,在分区内从左到右、从上到下寻找第一个空位。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact`、`attachment`、`agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸、间距和拖动阈值必须由单一前端布局模型常量维护。 -- 资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID;无论 `manuallyPlaced` 为何,已经写入的现存坐标都不得因重新排序、模式切换或新增资源被自动改写。 +- 资源卡是可点击按钮,只负责选择资源并让中央主视窗进入当前唯一资源聚焦状态;不得绑定卡片级 `pointerdown / pointermove / pointerup / pointercancel` 拖动处理器。 +- 指针移动不得修改卡片 `x / y`、不得产生拖动预览、不得更新依赖线几何,也不得提交手动布局 CAS。卡片 title、cursor、`touch-action` 和 class 不得暗示可拖动。 +- dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序,在分区内从左到右、从上到下寻找第一个空位。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact`、`attachment`、`agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸与间距由单一前端布局模型常量维护。 +- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的用户坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪或可信 producer / dependency depth 变化后按最终拓扑确定性重算。自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。 - 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。 - 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。 -- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;项目或 mode 已切换后返回的旧异步结果必须丢弃。 -- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。同一 scope 内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。 -- 某笔 CAS 在途期间,同一 scope 内对相同 `resourceId + section` 重复产生但尚未发送的拖动意图必须折叠为最后坐标;已经在途的请求不得取消,不同资源的顺序不得跨越。队列增长必须受当前资源与分区数量约束,不能随连续 pointer 事件无界累积。 -- 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。 -- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。即使当前在途请求是允许自动重试的资源协调,只要本次冲突实际清除了任何排队手动拖动,也必须按当前 scope 保留重新拖动提示;后续资源协调成功、失败或通用提示定时器都不得静默清除,只有新的手动布局成功保存或切换 scope 才能解除。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。 +- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready` 或 `failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。 +- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。当前 scope 内资源自动协调写入使用单写者 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。 +- 自动协调 CAS 冲突时直接载入返回的最新布局;仍需协调时可以基于权威 revision 最多追加 `2` 次重试,持续跨窗口写入时不得无限自旋。当前提示只说明“布局已在其他窗口更新”,不得要求用户重新拖动。 - 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。 +#### 5.2.5 暂缓的手动拖动合同 + +- Pointer Events 二维拖动、移动阈值、拖动态 class、乐观手动坐标与结束时单次 CAS 提交暂缓。 +- 手动意图折叠、冲突后丢弃旧手动坐标、重新拖动提示和 4096 卡片拖动帧预算暂缓,不进入当前验收。 +- 已有 Hook 手动意图、命令式 SVG preview、sidecar 字段和 Rust CAS 基础设施可以保留为未接线技术资产;当前资源卡不得调用这些入口。 + +#### 5.2.6 资源依赖关系图层 + +- 阶段五实现状态(2026-08-03):dependency 自动排列同时消费任务 DAG 与精确资源引用。Rust 把可信 producer 的任务深度作为资源深度下限,再对 `asset-reference` 图做迭代式 SCC 压缩与确定性层级传播;被引用资源位于引用资源之前,同一引用环共享稳定深度,环后资源继续递增,没有引用关系的资源保持默认不重叠位置。布局深度通过独立 `dependencyDepths` 返回,不能把 producer assignment 冒充全部资源的布局结果。 +- `producerMappingTruncated=true` 只关闭依赖有界 Agent DB 审计的 `producerAssignments`、`taskFlows` 和 `cyclicTaskIds`。Rust 返回的 `dependencyDepths` 仍是 manifest / 精确引用 read model 的权威结果,前端必须过滤未知资源、负数、非整数和非安全整数后继续消费;不得因 producer 截断清空全部深度,也不得在前端重算替代深度。`referenceEdges`、connection index 中的 reference 关系、`cyclicResourceIds` 和 unresolved reference 继续保持可信。 +- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。 +- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击。 +- `asset-reference` 表示精确资源引用,使用明亮橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。 +- `task-flow` 表示同一资源类型内的任务产物流转,使用灰色圆头虚线;文档、项目版本、美术、音频之间不得绘制跨分区虚线。任务依赖按 `sourceTaskId -> targetTaskId + section` 分区聚合为一条主线,两端只保留同分区资源并绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。 +- 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate` 的 `assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifest;External Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。 +- 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。 +- 资源自引用的起点与终点为同一张卡片时,必须绘制在卡片外侧的可见闭环并保留箭头,不得让路径穿过卡片后被底层 SVG 层级遮挡。 +- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。 +- 资源点击只进入中央聚焦并保留当前选中卡片,不改变依赖卡片或连线的颜色、线宽与透明度;关系线始终直接展示,不提供点击后的上下游高亮或无关线弱化。 +- 资源卡 Pointer Move 不改变基础 positions 或 SVG 几何。连线只随布局读取、资源自动协调、搜索、项目切换或 section origin 变化而更新。 +- `ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。 +- 阶段五不改变手动位置边界:已有 `manuallyPlaced=true` 坐标原样保留,资源引用新增或变化只允许重新派生 `manuallyPlaced=false` 的自动坐标;任务流继续按任务对与资源分区聚合,禁止为了计算深度或绘线生成资源笛卡尔积。 + ### 5.3 资源类型与替换兼容性(P1) +实现状态(2026-08-03):当前资源投影已收口到固定的“文档 -> 项目版本 -> 美术资源 -> 音乐音效资源”四区。文档只接收 Markdown / 文本 / JSON / YAML 等正式项目文档和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术接收图片、SVG、动画和视频类产物;音频只接收 manifest 已登记音频资产或已成功导入并登记到 manifest 的音频附件,任务声明中的未登记音频路径不冒充正式音频资源。无法识别的二进制任务产物和附件不进入资源画布。阶段四已为本地文档、安全 SVG / 扩展图片 / 视频和音频补齐受控读取、中央聚焦、失败空态与媒体播放;这些都是只读表现层,不改变资源投影或 manifest 真相。 + +资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。 + ```ts type ProjectResourceDescriptor = { resourceId: string; @@ -276,19 +309,29 @@ type ProjectVersionResourceReplacement = { ### 5.4 游戏迭代版本(P1) +阶段六实现状态(2026-08-03):正式版本业务真相扩展在本地项目 `.agent/manifest.json` 的可选 `versions` 字段中;旧项目字段缺失时等价于空列表,不根据 checkpoint、布局 sidecar、预览记录或 `game-creator-project-revision.v1` 自动伪造版本。版本数组只允许追加,已有记录不得删除、重排或修改;首轮没有版本创建按钮,也不自动把当前编辑态登记为版本。 + ```ts type GameIterationVersion = { versionId: string; parentVersionId: string | null; projectRevision: number; resourceBindings: Array<{ slotId: string; resourceId: string }>; - parameterSnapshotId: string; createdReason: 'initial' | 'resource-replacement' | 'agent-revision'; createdAt: number; }; + +type GameCreationAppManifest = { + // 既有字段省略 + versions?: GameIterationVersion[]; +}; ``` -版本写入后不可修改。 +- `versions` 按追加顺序保存。第一条必须是 `initial + parentVersionId=null`;后续记录必须引用数组中更早出现的父版本,创建原因不能再是 `initial`,从而天然排除自引用、悬空父版本和父子环。 +- `projectRevision` 与 `createdAt` 必须是 JavaScript 安全非负整数;子版本的修订必须严格大于父版本,创建时间不得早于父版本。 +- 同一版本内 `slotId` 唯一;`resourceId` 固定保存 manifest asset ID,不保存资源卡显示名称、External Editor resource ID、路径或布局 ID。历史资源已不在当前 manifest 时仍保留原绑定,但界面不为其合成资源卡。 +- Tauri manifest 存储边界在每次写入前校验完整版本图,并与磁盘中的旧 `versions` 前缀逐项比较;只允许追加新记录,已有记录被修改、删除或重排时写入失败且原文件保持不变。 +- 版本卡标题由稳定追加序号生成,卡片与聚焦态展示 `versionId / projectRevision / createdReason / parentVersionId`;聚焦态额外展示直接子版本和全部 slot 绑定。点击版本卡只高亮当前投影中唯一匹配 `asset:` 的资源卡,不修改版本或资源。 ### 5.5 测试切片与数值参数(P2) @@ -339,7 +382,7 @@ type ProjectAgentMudPointAttribution = { - 复用现有四区工作台壳。 - 资源/运行切换与客户端内 loopback 预览。 -- 只读资源画布、文档展开、美术/音频聚焦入口。 +- 固定四类资源投影、只读资源画布与中央主视窗资源聚焦;资源聚焦工具栏 / 工具侧边栏及后续媒体能力暂缓。 - Supervisor 正式会话、上传、Runtime 确认与安全错误。 - 当前 run 专业状态与项目历史成果分离。 - 默认三专业组,并可展开另外三组。 @@ -348,9 +391,9 @@ type ProjectAgentMudPointAttribution = { ### P1 -- 先实施依赖/类型两套坐标持久化、首次默认不重叠布局、跨重启恢复与 CAS 冲突处理。 +- 已实施依赖/类型两套坐标持久化、首次默认不重叠布局、历史坐标跨重启恢复与自动协调 CAS 冲突处理;资源卡手动拖动暂缓。 - 资源关系线在布局持久化验收通过后单独实施,不与本切片捆绑伪造完成。 -- 版本资源高亮、兼容性判断和不可变下一迭代版本。 +- 已实施正式版本只读模型、版本卡、父子关系与引用资源高亮;资源兼容性判断和不可变下一迭代版本创建仍待后续切片。 - 美术/音频编辑状态接线。 ### P2 @@ -371,21 +414,55 @@ type ProjectAgentMudPointAttribution = { 4. 底栏默认显示策划、美术、程序,可展开数值、音频、发布;状态与任务来自真实 Runtime/manifest。 5. 风险审批和无需审批不能改变运行策略,点击后明确提示尚未开放;严格审批继续使用现有 Runtime 门禁。 6. 不显示伪造泥点、伪造资源完成度、伪造图片或外部浏览器成功提示。 +7. 未识别任务产物不进入“项目版本”,只有正式版本 read model 可以生成版本卡;资源显示名称变化不改变资源身份。 +8. 点击任一资源后只替换中央主视窗,右侧对话与底部 Agent 状态栏保持原位;收起或按 Escape 退出后恢复原搜索、布局模式、滚动位置、选中资源和触发资源卡键盘焦点。 +9. 当前 Supervisor 运行期间 manifest 新增资产、任务状态、预览状态和正式版本后,工作台无需重开项目即可同步更新资源列表、依赖图输入、运行入口和版本卡;旧项目迟到回调不得覆盖当前项目。 +10. 实时 manifest 验收必须捕获真实 App Tauri listener,并让 `get_local_game_manifest` 在非 Supervisor Agent 的 Runtime / manifest 失效事件后返回新快照;测试不得直接调用 `onManifestChange` 冒充数据源。读取合并、旧项目迟到响应和项目切换隔离必须分别有回归证据。 +11. 音频或视频控件获得焦点后,同一资源 ID 的 manifest 更新不得把焦点移回详情 region;当前资源被删除后详情关闭、focused / selected ID 清理且焦点落到资源搜索框。显式收起与 Escape 的原卡片焦点和滚动恢复继续成立。 ### 7.2 P1 资源画布布局持久化验收 -1. 同一项目在 dependency 与 type mode 分别拖动资源后,关闭并重启客户端,两种 mode 都恢复各自最后一次成功保存的位置。 +1. 同一项目在 dependency 与 type mode 分别读取并协调布局,关闭并重启客户端后恢复各自 sidecar 坐标;历史 `manuallyPlaced=true` 坐标保持不变。 2. 新资源进入任一 mode 时获得不重叠默认位置,现存资源坐标保持逐项不变;删除资源后,下一次成功写入不再包含已确认失效的 ID。 -3. 资源不能跨 document、version、art、audio 分区;点击、搜索、筛选和资源详情浮层行为不因二维拖动回归。 -4. 两个窗口基于同一 revision 写入时最多一个成功;失败方收到 `conflict` 与最新完整布局,界面不静默覆盖成功方结果。 +3. 资源卡 title、cursor、`touch-action` 与 class 只表达可点击;Pointer Down / Move / Up / Cancel 不改变坐标、不产生拖动预览、不提交手动布局 CAS,点击仍打开当前资源详情。 +4. 两个窗口基于同一 revision 执行资源自动协调写入时最多一个成功;失败方收到 `conflict` 与最新完整布局,界面不静默覆盖成功方结果。 5. 布局文件缺失的旧项目可以无迁移打开;损坏、未知 schema、身份冲突、超限和链接文件失败关闭,且原文件不被空布局覆盖。 6. 布局读写不改变 manifest、游戏项目 mutation revision、Runtime verification、Agent 权限与预览状态。 7. `1280×800` 最小横屏下全部资源可通过分区滚动访问,不出现页面级横向或纵向溢出,右侧对话和底部 Agent 状态栏保持可见。 +### 7.3 P1 资源依赖关系图验收 + +1. dependency 模式显示对画布背景至少 `3:1` 对比度的橙色实线资源引用,并只在同一资源类型分区内显示灰色虚线任务流;跨类型不显示虚线,type 模式没有图层或连线。 +2. 精确引用只接受唯一有效的外部资源 ID 映射,删除或不存在的资源不产生幽灵连线。 +3. 多资源任务依赖按资源类型分区后,各分区只形成一条聚合主线与 `O(S+T)` 条端点分支,不产生 `S×T` 连线或跨分区虚线。 +4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。 +5. 搜索触发端点过滤;资源点击不改变上下游卡片或任何连线的视觉状态,资源卡指针移动不更新线段,点击与中央聚焦行为不回归。 +6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。 +7. 4096 资源链式 fixture 继续验证拓扑、聚合复杂度和自动布局性能;拖动局部更新与真实 Chromium 拖动帧预算暂缓,不作为当前验收条件。最右侧自环与箭头仍需完整显示。 +8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。Agent DB 有界读取截断时 producer、task flow 与 `cyclicTaskIds` 失败关闭,精确 manifest 引用及 Rust 返回的合法 `dependencyDepths` 继续到达布局层。重新打开包含深度 `0 / 1 / 2` 自动坐标的旧布局时不得降成全 `0` 或持久化扁平布局;手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。 +9. 依赖 SVG 作为装饰层不可聚焦并对辅助技术隐藏;画布通过关联的视觉隐藏文本逐条说明当前可见资源引用和任务流,搜索过滤或模式切换后文本与可见关系同步变化。 + +### 7.4 P1 正式项目版本阶段六验收 + +1. manifest 缺少 `versions` 时旧项目正常打开且不显示伪造版本;存在合法记录时,固定“项目版本”分区按追加顺序显示稳定版本卡。 +2. 根版本、父版本和直接子版本关系在卡片或聚焦态可见;悬空父版本、自引用、重复 ID、非递增修订、倒退时间、重复 slot 和超限数字均失败关闭。 +3. 点击版本卡后,当前 manifest 中仍存在的绑定资产卡被高亮;历史已删除资产只在版本详情保留 ID,不创建幽灵卡,也不把 External Editor resource ID 猜成 manifest asset ID。 +4. 版本聚焦态只读展示身份、修订、创建原因、父子关系、创建时间和 slot 绑定,不提供编辑、替换、切换、回滚或运行按钮。 +5. 任意现有 manifest 写入只能保留磁盘版本前缀并追加新记录;存储边界以跨进程专用锁串行覆盖旧状态读取、前缀校验、安装和回读,修改、删除、重排或并发旧快照覆盖已有版本时写入失败。 +6. 版本选择和高亮不写 manifest、布局 sidecar 或 project revision;dependency / type 两种布局都可显示绑定高亮,既有依赖关系 SVG 语义不变。 + +### 7.5 阶段七完整验收 + +1. 对照飞书需求、当前 PRD、技术方案、代码、测试与阶段提交复核阶段零至阶段六;美术编辑生成新资源继续按本 PRD 已确认的闭环条件暂缓,不作为遗漏或伪完成。 +2. AppSurface 同时覆盖文档、图片、SVG、音频和视频聚焦;视频必须使用原生 `controls` 且 `preload="metadata"`,读取策略失败时中央主视窗显示安全空态,右侧对话和底部 Agent 状态栏继续存在。 +3. `1280×800` 应用内浏览器实测 `window`、document 与 body 均无页面级横向或纵向溢出。浏览器开发页受真实登录门禁保护,不为验收绕过认证或伪造 Tauri;工作台内部结构由 AppSurface 集成测试与资源布局 CSS 合同测试复核。 +4. 根目录全量 Vitest、前后端 typecheck / lint / build、Rust workspace test / check、SpacetimeDB schema、原生壳、内容 / 编码、生产运维与部署门禁全部通过后,阶段七才允许提交。 +5. 本地 `.env`、`.env.local`、密钥、缓存、日志和构建产物不进入阶段七提交;提交前再次执行编码检查和 `git diff --check`。 + ## 8. 非目标 -- 资源画布布局持久化切片不实现资源关系线、资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因。 -- 本切片不保存资源详情浮层位置、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;这些状态如需持久化必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`。 +- 当前收口不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、下一迭代版本创建入口、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因。正式版本记录已经成为 manifest 业务真相,但当前只读取、校验和展示已有记录。 +- 本切片不持久化资源聚焦状态、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;聚焦退出时的列表上下文恢复只限当前前端会话,这些状态如需跨重启保存必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`。 - 不修改 SpacetimeDB schema。 - 不开放普通用户 Agent.md/Skill。 - 不自动确认 Agent 动作,不自动触发可能扣费的生成。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fab8fa0d3..00b604029 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,56 @@ # 决策记录 +## 2026-08-03 资源管理阶段七以完整 CI 与可重复界面合同收口 + +- 背景:飞书资源管理需求的阶段零至阶段六已经分别完成资源卡禁拖、固定资源投影、中央聚焦、安全文档 / 媒体预览、依赖深度与正式版本只读模型;最后需要统一复核需求边界并用当前主分支完整门禁排除集成回归。 +- 决策:阶段七不新增平行功能,只补齐视频原生控件和资源读取策略失败空态的 AppSurface 证据,运行完整前端 / Rust / 运维 CI,并以真实 `1280×800` 浏览器测量证明页面级无溢出。开发页的真实登录门禁不得为验收绕过,工作台内部以 AppSurface 与 CSS 合同测试复核;美术编辑继续等待画板回写、血缘登记、新资源自动选中与邻近布局闭环。 +- 影响范围:`apps/ai-game-creator-shell` 资源管理测试、跨平台 CI / 运维测试脚本、工作台 PRD、AI 游戏创作实施计划和共享项目记忆;不修改 SpacetimeDB schema、manifest 业务合同或资源布局 sidecar。 +- 验证方式:根目录全量 Vitest `171` 个文件、`2155 passed / 5 skipped`;追加 lint、build、Rust workspace test / check、schema、原生壳、内容 / 编码和生产运维门禁;应用内浏览器在 `1280×800` 下 document/body client 与 scroll 尺寸相等。 +- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-08-03 正式项目版本阶段六落在 manifest 追加不可变记录 + +- 背景:阶段一至五已经完成资源卡禁拖、固定分类投影、中央聚焦、依赖关系图和引用深度排列,但“项目版本”仍只能接受未接线的前端 read model;checkpoint、布局 sidecar 和项目 mutation revision 都不能代表正式可追溯版本。 +- 决策:本地 `.agent/manifest.json` 新增可选 `versions` 数组,旧项目缺失时只读为空。版本父子图使用父先于子的追加序列,Rust 在读写边界验证完整合同,并在覆盖 manifest 前要求已有磁盘版本是新版本数组的相等前缀,以此禁止修改、删除和重排。前端只从 manifest 投影版本卡;绑定 `resourceId` 固定解释为 manifest asset ID,点击版本在两种布局中高亮仍存在的资产卡。 +- 边界:阶段六只实现版本卡、父子关系、聚焦详情、引用资源高亮和不可变存储门禁;不自动回填版本,不创建下一版本,不做资源替换、运行版本切换、回滚、测试切片或运行态消费。SpacetimeDB、checkpoint、project revision 与布局 sidecar 均不改变。 +- 验证方式:共享 Rust / TypeScript 契约测试覆盖 camelCase 与缺省兼容;Tauri manifest 测试覆盖合法追加和历史修改拒绝;前端资源投影与 AppSurface 覆盖版本卡、父子详情、缺失历史资产和 dependency / type 绑定高亮,并运行 shell typecheck、编码检查与 `git diff --check`。 +- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-03 资源依赖阶段五以引用 SCC 深度驱动自动排列 + +- 背景:资源关系图已经能展示精确引用与聚合任务流,但 dependency 自动布局只消费可信 producer 对应的任务 DAG 深度;同一任务生成的派生资源、没有 producer 审计的 manifest 资源和资源引用环均无法稳定体现“被引用资源在前、引用资源在后”的顺序。 +- 决策:`read_local_project_resource_graph` 把 producer assignment 与布局深度拆成两个只读字段。Rust 先压缩完整任务图并把可信 producer 的任务深度作为资源下限,再对精确资源引用图做迭代式 SCC 压缩和确定性最长层级传播;同一引用环共享深度,环后资源递增一层,没有引用关系的资源保持默认深度 `0`。前端只校验并消费 `dependencyDepths`,沿用现有自动位置协调和 SVG 几何,不自行推导关系。 +- 边界:不修改 manifest、layout sidecar、External Editor API、api-server 或 SpacetimeDB;不恢复资源卡拖动。已有 `manuallyPlaced=true` 坐标继续保留,只有可派生自动坐标会按新深度重算;任务流继续按任务对聚合,不展开资源笛卡尔积。 +- 验证方式:Rust 定向测试覆盖无 producer 的精确引用、任务深度下限、资源引用环 SCC、环后资源与 4096 任务链;前端 DTO、布局 Hook、纯布局和 SVG 测试覆盖独立深度字段、自动重排与手动坐标保留,并运行 shell typecheck、编码检查和 `git diff --check`。 +- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-03 资源聚焦阶段四采用只读受控文档与媒体链路 + +- 背景:阶段一至三已经完成资源卡禁拖、固定四类资源投影与中央聚焦容器,但只有 PNG / JPEG / WEBP 和合法 Agent 文本回执具备真实主体预览;本地文档、SVG / 视频与音频仍只有路径和元数据。飞书需求同时把“编辑并生成新资源”写为条件项,而当前仓库尚未具备从画板返回后的血缘登记与自动选中闭环。 +- 决策:本地文档和媒体统一通过 Tauri 只读命令消费当前 manifest / 已完成任务登记范围,执行 `file.read` auto 权限、路径边界、普通文件、链接、大小、读取漂移与文件身份复核;文本限白名单格式和 UTF-8,Markdown 不执行 HTML、不加载远程图片、不提供活动外链;媒体按文件签名校验,SVG 拒绝活动内容与外部引用,音视频使用 WebView 原生控件。Agent 文本回执继续直接使用对话投影。当前不增加半成品美术编辑按钮,必须等画板回写、保留原资源、`referenceResourceIds`、新资源自动选中和邻近自动布局可一次闭环时再开放。 +- 边界:不修改 manifest、SpacetimeDB、资源投影身份、布局 sidecar、资源卡拖动或 External Editor API;不提供资源聚焦工具栏 / 工具侧边栏,不做音频编辑 / 替换或美术资源重生成。 +- 验证方式:Rust 单测与命令测试覆盖 UTF-8 / 扩展名、文件签名、活动 SVG、符号链接 / 硬链接、未登记资源、类别错配和权限;AppSurface 覆盖本地 Markdown 安全渲染、SVG data URL、音频播放器和中央聚焦状态;追加 shell typecheck、Rust 定向测试、编码检查与 `git diff --check`。 +- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-03 资源卡手动拖动暂缓并完成固定资源投影与中央聚焦 + +- 背景:飞书《陶泥儿GameAgent-V1.0 项目开发界面需求》曾要求资源卡可拖动,并由后续 PRD、技术方案和实现扩展为手动布局 CAS、依赖线拖动预览与性能验收;mentor 最新决定明确资源卡暂时禁止拖动,资源聚焦也不需要工具栏或工具侧边栏。 +- 决策:当前资源卡不绑定 `pointerdown / pointermove / pointerup / pointercancel` 拖动入口,title、cursor、`touch-action` 与 class 只表达可点击;Pointer Move 不改变坐标、不更新依赖线、不提交手动布局。资源投影固定为文档、项目版本、美术资源、音乐音效资源四区;未知任务产物不兜底为版本,版本只接受显式 read model,音频只接受正式登记资产或已导入附件。资源身份不使用显示名称。 +- 保留边界:项目内 dependency / type 布局 sidecar、历史坐标读取、资源集合自动协调、scope FIFO、跨窗口系统锁、Tauri/Rust CAS 与命令式 SVG preview 基础设施可以保留;历史 `manuallyPlaced=true` 坐标不删除、不重置、不迁移,但当前没有用户手动布局入口。手动拖动持久化、拖动性能和冲突后的重新拖动提示不再是当前验收条件。 +- 聚焦边界:点击资源后只把中央主视窗切换为 `resources.focused.document / art / audio / version`,不遮盖或替换右侧 Supervisor 与底部 Agent 状态栏;通用容器不提供工具栏、工具侧边栏、底部画板工具栏或可拖动标题栏。退出恢复当前会话内搜索、dependency / type、画布滚动位置和选中资源,不把这些状态写入 sidecar。 +- 后续边界:本阶段不新增项目文件读取、美术编辑、音频播放 / 编辑、版本替换或运行模块;若重新开放手动拖动,必须先更新 PRD、技术方案和 AppSurface 验收合同。 +- 验证方式:纯投影测试覆盖四类映射、未知产物拒绝和显示名称改动下身份稳定;AppSurface 覆盖 Pointer Down / Move / Up / Cancel 后卡片坐标、SVG path 与布局更新调用均不变,并覆盖中央聚焦、上下文恢复、dependency / type 切换、搜索、依赖图和直接上下游高亮;追加 shell typecheck、编码检查与 `git diff --check`。 +- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-03 依赖布局等待 Rust 图终态且拖动热路径脱离 React state + +> 状态:其中依赖图初始化屏障继续生效;手动拖动热路径与性能验收已由上一条 mentor 最新决定暂缓。 + +- 背景:资源图异步返回前,dependency 布局会先以 `dependencyDepth=0` 创建并持久化自动坐标;图返回后的 reconcile 保留既有位置,导致首次布局永久停留在错误层级。4096 张真实资源卡拖动时,逐帧父组件 state 还会重渲染全部卡片,即使 SVG 已只更新局部 path 也无法满足帧预算。 +- 决策:Rust 关系图 read model 负责在完整任务图 SCC 压缩后返回确定性 resource dependency depth;dependency 模式等待当前 scope 图进入 `ready / failed` 后才启动布局读取与协调。手动位置永久保留,自动位置允许按最终图重新派生。拖动 preview 留在前端 ref/DOM 热路径,命令式更新卡片 CSS 与局部 SVG path,不逐帧跨 Tauri IPC,也不写 layout sidecar。 +- 边界:不修改 `game-creator-resource-layout.v1`、布局 Rust 持久层、manifest、api-server 或 SpacetimeDB;type 模式不等待资源图且继续保留全部已有坐标。图失败只降级初始化一次,项目或 mode 切换后旧图结果必须丢弃。 +- 验证:延迟图 Promise 证明终态前零布局读取/写入,手动位置保持且自动位置按最终深度协调;4096 张真实卡片连续拖动证明非拖动卡片零重渲染、静态 SVG 不重建、Observer 单实例,并以 Chromium p95 `<16.7ms` 和零 `>50ms` long task 验收。 + > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 ## 记录格式 @@ -16,6 +67,21 @@ --- +## 2026-07-31 AI 游戏创作资源依赖图采用 Rust 只读拓扑与前端派生 SVG + +> 状态:其中资源卡 Pointer Move 拖动预览与局部更新验收已由 2026-08-03 mentor 最新决定暂缓;只读拓扑、SVG 派生展示、搜索与选择高亮合同继续生效。 + +- 背景:资源画布已有 dependency / type 双模式坐标与本地 CAS sidecar,但 dependency 模式尚未把当前 manifest 中可证明的资源引用和任务流转可视化;关系图不能反向污染布局持久化或建立第二套资源真相。 +- 决策:dependency 模式由 Tauri Rust 只读命令从当前 manifest、资源卡身份和有界 `.agent/agent.db` 审计构建稳定 `ProjectResourceGraph` read model,前端只归一化 DTO、测量卡片坐标并用原生 SVG 渲染。资产 `source.referenceResourceIds` 只在唯一匹配另一资产 `source.resourceId` 后形成橙色实线;任务依赖按任务对聚合为灰色虚线主线与两端分支,禁止资源笛卡尔积。Rust 以迭代式强连通分量分析识别资源环和完整任务 DAG 环,并返回资源局部连接索引。 +- 任务身份:External Editor 响应中的 `source.taskId` 是平台生成任务 ID,不等于本地 manifest task ID,禁止据此分配 producer。画布资产只接受 `agent.runtime.canvas.asset_generate` 审计中经当前 manifest task 校验的 `assetId -> agentId`;证据缺失、冲突或已超出有界读取窗口时不生成对应 task flow。任务产物与 Agent 回执继续使用自身已有的 manifest task 身份。 +- 生命周期与边界:Pointer Move 先用 `requestAnimationFrame` 合帧;基础 positions 与拖动预览分离,SVG 静态拓扑保持复用,每帧只按局部索引更新拖动资源关联的 reference edge 和 task flow。`ResizeObserver` 在单个图层生命周期只创建一次。type 模式不挂载图层;切换 mode、项目或卸载工作台时销毁 SVG、Observer 和窗口监听。SVG 统一 `pointer-events: none`;path、marker、图结构和 section 原点从不持久化。 +- 数据边界:本切片只新增 Tauri Rust 只读 read model,不修改 layout sidecar、`resourceCanvasLayoutModel.ts`、manifest、api-server、SpacetimeDB schema 或生成绑定,也不引入第三方图表库。dependency section 只在显示层额外预留 `64px` 右侧视觉 gutter,卡片坐标和持久化布局不变。 +- 影响范围:`apps/ai-game-creator-shell` 的 Tauri project read model/command、项目开发资源投影、依赖图 DTO、SVG overlay、样式与前后端测试,以及工作台 PRD 和客户端实施计划。 +- 验证方式:Rust 定向测试覆盖真实 producer 映射、拒绝复用外部 `taskId`、证据缺失、去重、无效 ID、完整任务环、4096 任务链与聚合复杂度;前端模型和 SVG 测试覆盖 DTO 防御过滤、局部上下游、可见资源自引用闭环、搜索、高亮、单帧局部 path 更新和稳定 Observer;AppSurface 覆盖生产数据形状、两种边、type 模式卸载和项目切换销毁,并运行 shell typecheck、编码检查与 `git diff --check`。 +- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +--- + ## 2026-07-30 抠图实际后端作为 generationInputs 顶层内部元数据保存 - 背景:角色、图标图集和 UI 图集抠图派生资产需要保留最终实际执行的处理后端,供后台诊断 BgFilter、阿里云通用抠图和本地键色的降级结果;把抠图模型写成 `generationInputs.fields` 的“处理模型”会进入图片信息,与用户可见输入快照语义冲突,而覆盖正式资产 `model` 又会丢失源生图模型。 @@ -5101,6 +5167,7 @@ - 决策:新增 `command.start / command.poll / command.stdin / command.terminate` 四个模型工具,专门承载受控前台持久进程。start / stdin / terminate 默认 `confirm`,poll 默认 `auto`。`command.start` 复用 `command.exec` 的固定 program、逐项 argv、项目 cwd、白名单解析、安全 PATH、隔离环境和参数拒绝,不接受 shell、环境注入、用户 executable、管道、重定向或 daemonize / detach;Runner 直接持有固定 `120x30` PTY、child handle、stdin writer 和输出泵,同项目最多 4 个、同 Agent instance 最多 2 个 running session。V1.2 对 PTY / 后台进程的排除只适用于一次性 `command.exec`。 - 决策:`processId` 是 start action create-once 的 opaque Runtime 身份,完整绑定 project、Agent instance、task、session、run、start action、action / command fingerprint 和 Runner boot;它不是 OS PID。poll / stdin / terminate 每次都从活 registry 和 durable record 交叉复核 owning 身份,跨 Agent、动态 sibling、run 或项目一律失败关闭,不能把知道 ID 当成授权。 - 决策:2026-07-27 起,独立 Runner 归 Tauri GUI 生命周期所有,同一 AppData 通过 OS GUI owner 锁只允许一个前端进程持有 Runner。GUI 启动子进程显式携带 `--gui-owner-required`,Runner 若在启动检查前发现 owner 已释放就直接失败,不能退化成 CLI-owned Runner;就绪后仍必须调用 `runner.attach_gui_owner`。2026-08-05 补充:GUI 客户端把完整 attach 参数按规范化 AppData 保存为进程内登记,并在 `ensure_external_agent_runner` 复用或新启 endpoint 的成功出口按 `bootId` 重放;同一登记 generation 在同一 boot 上只发送一次,新 boot 必须在后续 Runtime 写请求取得 endpoint 前完成登记。只有 Runner 明确确认 attached 后才能记录成功 boot,失败时本次 ensure 失败且后续同 boot 继续重试;登记 mutex 只做快照和成功提交,网络请求期间不持有,锁序固定为 configure lock 后 registration mutex。普通 CLI 没有 GUI 登记,不得因启动、写入或只读 status 产生 attach 副作用。OS owner/watchdog 已建立不代表事件 sink 等进程内附加能力已恢复。Runner 由独立 watchdog 线程每 100ms 探测 owner 锁,不依赖服务端主循环;owner 丢失后先标记 draining / forced shutdown 并让服务端在 1.5 秒共享 deadline 内中断 Provider、回收 process session,若主循环或排空链路卡死则 watchdog 在 1.75 秒后复核 bootId、清理 endpoint 并由 Runner 自身进程硬退出。正常最终 `RunEvent::Exit` 仍同步请求专用 `runner.shutdown`;GUI panic、SIGKILL 或构建中途失败不再只依赖退出回调。`runner.shutdown` 不得复用版本切换用的 `runner.shutdown_if_idle`,也不得以 busy 为由继续留在后台。GUI 侧使用专用短连接 / I/O 超时;endpoint 缺失或读取失败不能单独证明 Runner 已退出,必须结合实例锁释放,失败日志只输出脱敏阶段分类。GUI 客户端兜底在 Linux 通过同一 pidfd 校验 / 发信号,Windows 绑定同一进程 handle;macOS 没有等价稳定句柄,客户端不得按裸 PID 强杀,由跨平台 Runner 自身 watchdog 承担主循环卡死的最终兜底。旧 endpoint 缺 start identity 时,只有 GUI owner 路径且认证 ping 同时精确匹配 PID 和 bootId,才允许一次性迁移 busy 旧 Runner;普通 CLI 仍必须被 busy 阻断,不能按相同二进制猜测强杀。客户端强制终止后必须先取得同一 Runner 实例锁,再在锁内复核 bootId 并清理 endpoint;Unix endpoint 必须是当前用户持有的 0600 单硬链接普通文件。退出不得把任务伪造为 completed、不得重放工具副作用;未完成 run 保留既有 durable 状态,下一次启动按 reconciliation / recovery 合同处理。单个 WebView/子窗口关闭不触发 Runner shutdown,普通 CLI 退出也保持原行为,显式 `--runner-shutdown-if-idle` 仍只用于安全关闭空闲 Runner。Runner 重启只做 reconciliation:旧 boot 已进入 prepared / launching / running / terminating 且没有可信 terminal record 的会话进入 `needs-reconciliation`,不得重放 start 或 stdin,不得重发 terminate,也不得按持久化 PID 重连或接管 PTY;可信终态只补 observation / audit / receipt。首版连旧 boot 的 prepared 也保守核对,不自动推断为安全重试。 +- GUI owner attachment 的完整成功条件固定为 `attached=true` 且 `eventSinkAttached=true`,登记保存并逐 boot 重放真实 sink port/token;任一确认缺失或失败时不得写入 `attached_boot_id`。sink token 不得进入日志、错误信息或公共状态。 - 决策:`command.poll` 使用绑定 processId 的 opaque cursor,并以 `maxChars / waitMs` 分页读取保留逻辑行边界的清洗后私有 PTY transcript;默认 / 最大返回 8,000 / 16,000 字符,最长等待 30 秒,同一 action/cursor 恢复必须稳定。后台输出泵独立等待 child 并排空尾部,单会话清洗后输出上限为 256 KiB,超限终止并落 `output-limit-exceeded`。输出正文只进入 owning Agent 的私有 transcript、observation 和 context bundle,task/event/Agent DB/receipt/action history/activity/output/UI snapshot/report 只保存 cursor、字节数、SHA-256、截断和退出元数据。`command.stdin` 单次最终 UTF-8 bytes 上限 8 KiB,支持 `appendNewline / eof`,是不可重放副作用;公共确认与审计只留 `processId / bytesWritten / contentSha256 / stdinOpen / eof`,不得保存 data、摘要、前后缀或可逆编码。 - 决策:owning run 存在 launching / running / terminating 或未解决 reconciliation 会话时,final reply、finalization journal 和 completed 投影全部阻断。`runner.shutdown_if_idle` 同时检查活 registry、输出泵、终止任务和 durable unresolved record;取消 run 也必须先完成进程收束,不能留下会话后把 Runner 判 idle。 - 决策:terminate 必须携带最后一次 poll cursor,并返回同一 cursor 的零消费状态元数据;后续 poll 不得从 0 重读或跳过尾部。Unix 固定为 graceful request + 完整固定宽限等待、随后只 force kill 同组残留、再 wait / reap / drain PTY;Windows 首版使用 Job force terminate + wait / reap,不宣称已有等价 graceful console event。只发送信号不算完成;signal / Job / wait / reap 或终态审计无法确认都进入 reconciliation。重复 terminate 只幂等返回已知终态,不能按 PID 再杀一次。 @@ -5530,7 +5597,7 @@ - 资源投影:上述回执同步投影到“资源管理 → 文档”,保留来源 Agent 和 run 身份。它是持久回执的可见视图,不得冒充 manifest asset、项目目录中的实际文件或可下载交付物。 - 重试确认:`agent.resume` 默认仍为 `confirm`。普通自动 retry command 保留 auto gate;正式失败卡的“在当前项目重试”按钮本身视为本次明确确认,调用单独的 confirmed retry command,但仍不得绕过 deny。点击后必须在原卡即时显示提交中、成功或安全错误,不能把错误放到专业列表末尾。若总控已为同一 delegation 准备精确 repair,按钮优先确认该 repair,不再创建重复的无合同重试。 - 回执命名:无文件的 completed 结果统一称“专业 Agent 文本回执”,不得称“美术产物”或直接暴露 `design-foundation / art-asset-plan / balance-seed` 等内部 ID。美术任务只完成计划且 manifest 没有图片时,普通界面明确显示“仅完成计划,尚未生成或登记图片”。 -- 工作台布局:PDF 方案外的顶部项目标题条不进入项目工作台;资源卡支持同分类、当前会话内的真实拖拽重排,不宣称持久保存。资源详情使用独立可拖动浮层,位置约束在工作台与 viewport 内并避开底部 Agent dock,长正文独立滚动。工作区与 dock 精确占满客户端可用高度,不保留 dock 下方空白。 +- 工作台布局:PDF 方案外的顶部项目标题条不进入项目工作台;本条原定的资源卡同分类、当前会话内拖拽重排已由 2026-08-03 mentor 最新决定取代,当前资源卡只允许自动布局与点击聚焦。原独立可拖动详情浮层已被同日后续阶段三替换为中央主视窗资源聚焦状态,右侧对话与底部 Agent dock 常驻,长正文在聚焦主体内独立滚动;退出恢复当前会话的列表上下文。工作区与 dock 精确占满客户端可用高度,不保留 dock 下方空白。 ## 2026-07-20 AI 游戏创作策划与美术图片交付门禁 @@ -5959,6 +6026,19 @@ - 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。 - 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。 +## 2026-08-04 AI 游戏项目 manifest 存储与工作台实时投影 + +- 存储决策:`.agent/manifest.json` 的版本追加不可变约束由同目录持久专用锁保护,读取旧状态、校验版本前缀、安装临时文件和安装后回读必须处于同一临界区;进程内 Mutex 不能替代跨进程文件锁。 +- UI 决策:Project Supervisor 持有运行中 manifest 状态并向外层启动器同步完整快照;外层项目上下文继续是工作台投影的唯一输入,只接受当前项目路径的更新,不另建资产、任务或版本平行状态。 +- 依赖图决策:Agent DB 尾部读取一旦截断,审计 producer、task flow 与对应 `cyclicTaskIds` 失败关闭;独立 `dependencyDepths` 仍由 Rust 从当前 manifest、精确资源引用和仍可信的任务深度下限构建,前端只做资源存在性与非负安全整数校验后继续消费。manifest 精确资源引用、reference connection index、资源环和 unresolved reference 与审计生产者证据分离。SVG 保持装饰性,辅助技术消费画布关联的文本关系列表。 + +## 2026-08-05 AI 游戏项目实时 manifest 失效与资源焦点状态机 + +- 失效源决策:后台 `task.update`、`canvas.asset_generate`、任务起止 / 终态投影、正式版本追加和 autonomous manifest reset 都处于 Runtime 动作或生命周期内,并在写入后回到共用 Runtime emitter;因此以该 emitter 作为统一 manifest 失效因果点,不在 WorkspaceLauncher 新增平行回调,也不轮询 manifest。Rust / TypeScript 的 `game-creator-agent-runtime-update` 合同增加 `manifestInvalidated`,App 在全部 Supervisor、selected agent、session / run early return 之前消费它。 +- 跨进程决策:External Runner 没有 GUI `AppHandle`,不能假设普通 Tauri Runtime event 会跨进程到达。Runner IPC 协议升级为 v5,GUI owner attach 同时登记 GUI 创建的 loopback 随机端口和 64 位随机十六进制令牌;Runner 内同一 Runtime emitter 发送最小 `projectPath + agentId` relay,GUI 校验令牌后转成 `game-creator-manifest-invalidated`。GUI 内 Runtime 继续直接发送完整 Runtime update。两条路径汇合到同一个 App manifest 重读器。 +- 重读决策:`get_local_game_manifest` 按项目 single-flight;同项目读取中再次失效只排队一轮后续读取,不启动并发请求。响应应用必须同时匹配 mounted、活动项目路径和 project scope version,旧项目、旧 scope 或卸载后的响应全部丢弃。重读后的 App state 继续沿既有 `onManifestChange -> currentProjectContext -> ProjectDevelopmentView` 单向投影,不复制资产 / 任务 / 版本状态。 +- 焦点决策:资源详情焦点以稳定 `resourceId` 的转换而非重建后的资源对象决定。`null -> id` 和 `idA -> idB` 聚焦详情;`idA -> idA` 保留详情内部 active element。显式收起 / Escape 恢复滚动并优先返回触发卡片;资源已删除时清理 focused / matching selected ID 并聚焦资源搜索框;项目或运行视图切换清除旧 trigger 与 restore 标志,禁止跨项目恢复。 + ## 2026-08-04 图片画布素材类型采用资源默认值与布局覆盖双层模型 - 背景:画布复制逻辑曾为副本生成 `local-resource-copy-*`,导致同一媒体被伪装成未登记资源;随后改为复用 `resourceId`,但手动修改图层标签仍通过“按新 `assetKind` 查找 / 创建项目资源并换绑当前图层”实现。这会让单纯标签修改增加资源行、漂移 `resourceId`,并在异步回填与复制交错时形成“新类型 + 旧资源”的副本。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 86f665e16..62c76d604 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -94,6 +94,12 @@ npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts --run cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml autonomous_completion_contract -- --nocapture --test-threads=1 ``` +修改 manifest invalidation relay、GUI owner attach 或其测试夹具后,所有会读写进程全局事件 sink 的测试统一使用 `manifest_invalidation_sink_isolation_` 前缀,并至少以 2 个 test thread 重复运行该 filter。测试 fixture 的 accept 和 payload 读取都必须使用总 deadline,不能只在 accept 成功后给 `TcpStream` 设置 read timeout;全局 sink 只能在共享 test-only 串行锁内由 RAII guard 配置和清理。 + +```bash +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml manifest_invalidation_sink_isolation_ -- --nocapture --test-threads=2 +``` + source allowlist、game-chat 单轮完成门和 Canvas spritesheet 引用门禁均须命中实际测试;若完整 Rust suite 受 Windows `os error 32` 既有文件锁竞态影响,应单独复跑新增 filter 并如实记录,不能把锁竞态失败改报为本次改动通过。 Windows release 的非交互后台命令统一使用 `CREATE_NO_WINDOW`,包括 `command.exec / project.verify`、STDIO MCP、Repository Context Git、`git.inspect / project.git_commit` 和 `taskkill` 清理命令;需要进程组终止时再叠加 `CREATE_NEW_PROCESS_GROUP`,不要使用 `DETACHED_PROCESS`。smoke 时应在实际任务运行期间观察无额外控制台窗口,并在关闭客户端后核对整棵后台进程树为零,再重启确认 reconciliation 可继续。 @@ -541,7 +547,7 @@ npm run check:server-rs-ddd - 仓库 CI 入口是 `.gitea/workflows/project-ci.yml`,向 `master`、`codex/ai-game-creator-app` 推送和所有 PR 创建、更新时必须运行,也允许手工触发。 - CI 固定拆分为 `Repository checks`、`Frontend tests`、`Backend tests`、`Native shell tests` 四个 required job;对应 PR context 完整名称是 `Project CI / Repository checks (pull_request)`、`Project CI / Frontend tests (pull_request)`、`Project CI / Backend tests (pull_request)`、`Project CI / Native shell tests (pull_request)`,首次运行后仍须从 Gitea 最近一周 context 表复核。测试使用独立 job,不能只藏在综合检查 step 中;原生壳验收单独运行以便定位重型构建失败。 -- 四个 job 共同覆盖 `npm run check`,并追加 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --all-targets --manifest-path server-rs/Cargo.toml` 和 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。后端 runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。`codex/ai-game-creator-app` 分支必须用独立 lockfile 安装 AI 游戏创作壳依赖,其原生壳入口还必须覆盖 `npm run ai-game-creator-shell:check`、release build smoke,并检查 AI Tauri `Cargo.lock` 不漂移。原生壳 job 还要通过 Google 官方签名 APT 源安装 `google-chrome-stable`,用于 headless preview 的真实 DOM / canvas smoke;同时安装 `ripgrep`,把 `actions/setup-node` 的完整 Node.js 22 发行目录与 root-owned rustup proxy 映射到 `/usr/local`,供只接受受信任系统命令目录的 `command.exec` 沙箱测试使用,不能放宽生产命令目录白名单。Tauri 的 1132 项级别 suite 固定 `--test-threads=1`,避免共享 Agent Runtime 后台锁与异步终态在 libtest 并行调度下互相干扰。 +- 四个 job 共同覆盖 `npm run check`,并追加 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --all-targets --manifest-path server-rs/Cargo.toml` 和 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。Backend job 必须先用 `cargo fetch --locked` 的 5 次整命令级有界重试准备当前 `server-rs` 锁定依赖,再进入会触发 `cargo build` 的 DDD / module-runtime 产物边界门禁;不得把依赖准备放在该门禁之后,否则镜像缺少锁新增 crate 时会在首次 registry / TLS 抖动处提前失败。后端 runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。`codex/ai-game-creator-app` 分支必须用独立 lockfile 安装 AI 游戏创作壳依赖,其原生壳入口还必须覆盖 `npm run ai-game-creator-shell:check`、release build smoke,并检查 AI Tauri `Cargo.lock` 不漂移。原生壳 job 还要通过 Google 官方签名 APT 源安装 `google-chrome-stable`,用于 headless preview 的真实 DOM / canvas smoke;同时安装 `ripgrep`,把 `actions/setup-node` 的完整 Node.js 22 发行目录与 root-owned rustup proxy 映射到 `/usr/local`,供只接受受信任系统命令目录的 `command.exec` 沙箱测试使用,不能放宽生产命令目录白名单。Tauri 的 1132 项级别 suite 固定 `--test-threads=1`,避免共享 Agent Runtime 后台锁与异步终态在 libtest 并行调度下互相干扰。 - checkout 必须使用完整历史。PR 将 base SHA 写入 `SPACETIME_SCHEMA_BASE_REF`,直接推送 `master` 使用 before SHA;事件基线不可解析时直接失败。Gitea 检查的是 PR head 而非预合并 commit,workflow 必须拒绝不包含最新 base commit 的过期 PR,分支保护同时保持“PR 过期禁止合并”。 - 普通 PR job 不读取业务 secret,不运行真实 API/SpacetimeDB/OSS/支付/生成/live smoke,也不执行会修改外部状态的维护、迁移、发布或备份命令。 - Gitea 至少升级到 `1.26.4` 后才能注册执行 PR job 的 runner;`ubuntu-latest` 标签只映射到固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像,不使用浮动镜像 tag,不映射 host,不向 job 暴露 Docker socket、业务 secret 或不必要内网。runner 能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm、Rust 分发、crates.io 和 Google Chrome 的 `dl.google.com` 官方签名 APT 源;workflow 的官方 action 固定完整 commit,若内网禁用 GitHub,先在当前 Gitea 镜像对应 commit 并改用绝对 URL。受控镜像优先预装 rustup。Gitea 1.26 的任务超时由 runner 全局配置控制;首次运行成功后,`master` 分支保护必须要求上述四个 job 全部成功。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 86144242b..cfe8ceb4f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,30 @@ - 关联:相关文件、文档、提交或 Issue ``` +## Linux 生产脚本门禁不能假设本地也是 GNU userland + +- 现象:macOS 本地运行维护页、生产 API 部署和 Rust 产物门禁时,依次出现 `mv: illegal option -- T`、`mapfile: command not found`、`/usr/bin/cp` / `/usr/bin/chmod` 不存在,以及 `.rlib` 明明含有 `.o` 却报告“没有可扫描成员”;安全修复计划还会把 `/var/folders` 到 `/private/var/folders` 的系统别名误判为用户符号链接。 +- 原因:生产机是 Linux/GNU,而本地门禁运行在 BSD userland、Bash 3.2 和 BSD ar;测试桩硬编码 Linux 二进制路径与参数,归档解析器没有去掉 BSD 扩展成员名的尾随 NUL,路径校验也直接比较了未规范化字符串。 +- 处理:维护 marker 使用同目录临时文件加 POSIX `mv -f`,并在替换前拒绝所有符号链接和目录目标,避免 `mv -f` 跟随目录链接把临时文件移入链接目标;生产部署测试桩在 macOS 忠实模拟 GNU `mv/ln -T` 的“目标不是目录”语义,并按平台选择系统工具;脚本收集服务使用 Bash 3.2 可用的 `while read`;rlib 解析清理 BSD 成员名 NUL;计划文件只规范化系统临时目录别名,仍拒绝其下用户创建的符号链接组件。 +- 验证:运行 `npm run check:maintenance-page`、`npm run check:production-api-deploy`、`npm run check:server-rs-ddd`、`npm run test -- scripts/spacetime-repair-editor-canvas-resources.test.ts`,并在 Linux CI 保留同一生产脚本语义。 +- 关联:`scripts/deploy/maintenance-on.sh`、`scripts/check-maintenance-page.mjs`、`scripts/check-production-api-deploy.mjs`、`scripts/deploy/production-api-deploy.sh`、`scripts/check-module-runtime-artifact.mjs`、`scripts/spacetime-repair-editor-canvas-resources.mjs`。 + +## External Editor taskId 不能当作本地 manifest taskId + +- 现象:画布资产之间已有橙色精确引用线,但依赖任务之间没有灰色 task flow;测试用 `design-foundation` 之类字符串时正常,真实生成返回 `task-1` 后失败。 +- 原因:`GameCreationAppAssetSource.taskId` 保存的是 External Editor 生成任务身份,命名空间与本地 `.agent/manifest.json` 的 Agent/task 身份不同;前端用 `taskById.get(source.taskId)` 会让真实画布资产全部失去 producer。 +- 处理:资源依赖图的 Tauri Rust read model 从有界 `.agent/agent.db` 读取 `agent.runtime.canvas.asset_generate`,以 `assetId -> agentId` 映射 producer,并要求 `agentId` 存在于当前 manifest。记录缺失、多个不同有效 Agent 冲突或读取已截断时失败关闭 producer assignment、task flow 与对应 `cyclicTaskIds`,不回退 `source.taskId`。精确 `asset-reference` 仍只依赖 manifest 中外部 resourceId 的唯一匹配;Rust 独立返回的 `dependencyDepths` 继续作为 manifest / reference read model 权威结果,前端只过滤未知资源、负数、非整数和非安全整数,不得因 producer 截断把它整体清空。 +- 验证:Rust fixture 把 `source.taskId` 固定为 `task-1 / task-2`,只有审计提供 `art-director / design-foundation` 后才生成 task flow;移除或截断审计后橙色引用保留、灰色任务流消失,合法深度仍为 `asset:spec=0 / asset:ui=1`。AppSurface 使用截断生产数据形状证明深度 `0 / 1 / 2` 真实到达卡片布局,并且不会把已有自动坐标持久化成扁平布局。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs`、`apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 依赖图未就绪时不能先初始化资源布局 + +- 现象:首次打开 dependency 画布时所有资源短暂按深度 0 排列;Rust 图返回后连线正确,但卡片仍停留在同一列,错误自动坐标还可能已经写入 sidecar。 +- 原因:资源图和布局读取独立异步启动,布局 Hook 在图未返回时使用空图资源创建 fallback;后续 reconcile 按旧合同保留全部已有坐标,真实 producer 与 dependency depth 无法纠正首次自动位置。 +- 处理:dependency 模式增加按项目与资源输入隔离的图加载屏障,`ready / failed` 前不启动布局 Hook 的 fallback、读取、协调或保存。Rust read model 返回确定性依赖深度;已有布局只永久保留手动位置,自动位置按最终图重新派生。type 模式不受图加载影响。 +- 验证:用 deferred graph Promise 断言终态前 Tauri layout read/update 调用均为 0;图就绪后首次坐标直接按最终深度生成,旧 scope 迟到结果无效,手动坐标不变且相同自动布局不增加 revision。 +- 关联:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs`。 + ## Jenkins 异步备份不能用 nohup 脱离作业 - 现象:Stdb Publish 成功,上传日志只留下“已获取进程锁 / 上传已有备份 / 目标对象”,没有成功或可捕获错误;本地 tar.gz 和 `uploadStatus=deferred` manifest 每次发布后继续增长。 @@ -3707,6 +3731,7 @@ - 原因:`libc::stat.st_dev` 跟随平台 `dev_t`,macOS 为有符号整数,而 `std::os::unix::fs::MetadataExt::dev()` 统一返回 `u64`;直接比较会把 Linux 的类型偶合误当成 Unix 通用契约。 - 处理:与 Rust 标准库的 Unix `MetadataExt` 实现保持一致,先把 `st_dev / st_ino` 规范为 `u64`,再与 `metadata.dev() / metadata.ino()` 比较;设备号、inode 和文件类型三重检查均必须保留。 - macOS 测试夹具:`std::env::temp_dir()` 可能返回 `/var/folders/...`,而 `/var` 是系统兼容符号链接。需要真实项目根的 Runtime 测试应先 canonicalize 已存在的临时根目录,再创建唯一子目录;不得为了让夹具通过而放宽生产 Runtime 的项目根及祖先符号链接拒绝规则。 +- 异步测试隔离:测试触发后台 continuation 后,必须等待对应 Agent lane 完整释放,再删除项目夹具或安装下一项全局 mock 配置;否则前一项后台任务可能抢占后一项的唯一 mock 响应,形成只在全量顺序执行时出现的跨测试污染。 - 验证:macOS 本机运行 `cargo check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,并复跑 Agent DB、project owner 和 tool-plan handoff 的 Unix 相对句柄替换检测;Linux CI 继续覆盖原有安全回归。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project.rs`、`runner.rs`、`tool_plan_handoff.rs`。 @@ -4085,6 +4110,27 @@ - 现象:可选 server 已成功连接,但返回超限 schema、重复 tool identity 或要求未支持 task-mode 时,整个 MCP catalog 和本轮 Agent planning 一起失败。 - 处理:连接、tools/list、工具归一化与聚合容量都使用同一 required / optional 边界。optional 将该 server 投影为 `connected=false + error + tool_count=0`,required 保持失败关闭;被包入 `action.input` 的 `$ref` 只重定位当前 document 根的 `#` / `#/...` JSON Pointer,命名 anchor、外部 URI 与带 `$id` 的 schema resource 内 fragment 不得改写。 +## React 异步读取必须在组件卸载时中止并失效(2026-08-04) + +- 现象:单个 Vitest 文件全部通过,全量 CI 却在 jsdom 环境销毁后出现 `ReferenceError: window is not defined`;栈指向请求 Promise 的 `finally` 中调用 React `setState`。 +- 原因:测试触发了与断言无关的账户读取,较快环境中请求会在用例结束前失败,较慢 CI 中请求延迟到组件和 jsdom 均已销毁后才收束。仅用 revision 丢弃旧请求而不在卸载时推进 revision,最后一个在途请求仍会被误认作当前请求。 +- 处理:调用方在未认证时不得启动受保护的钱包刷新;可取消的读取要为每轮分配 `AbortController`,新读取先失效并中止旧读取,组件卸载时同时推进 revision、abort 当前请求并清空句柄。所有 `then / catch / finally` 在更新状态前都要检查 signal 与 revision。 +- 验证:定向测试覆盖卸载后请求 signal 已中止;同时复跑触发钱包刷新回调的画布生成集成测试和完整前端测试,不能以单文件偶然快速收束代替全量验证。 + +## 下游 manifest 回调测试不能冒充实时数据源(2026-08-05) + +- 现象:工作台的资源、任务与版本重投影单测保持绿色,但后台 Agent 已更新 `.agent/manifest.json` 后,打开中的工作台仍长期显示旧快照,只有重开项目才更新。 +- 原因:测试 Supervisor 直接调用 `onManifestChange`,只证明 `App manifest -> WorkspaceLauncher -> ProjectDevelopmentView` 的下游桥接;真实 Runtime event 没有失效字段,监听器也没有重读 manifest。External Runner 又与 GUI 分属不同进程,Runner 内无法使用 GUI `AppHandle`,只补普通 Tauri event 仍不能形成生产链路。 +- 处理:后台 manifest mutation 收敛到共用 Runtime emitter;GUI 内进程用带 `manifestInvalidated` 的 Runtime update,External Runner 通过 GUI owner attach 登记的受令牌保护 loopback sink 转发专用失效事件。App 对当前项目做 single-flight manifest 重读,并以 mounted、项目路径和 scope version 丢弃迟到结果;WorkspaceLauncher 继续只消费完整 manifest 快照,不新增平行状态或轮询。 +- 验证:集成测试必须渲染真实 `App + WorkspaceLauncher`、捕获真实 Tauri listener,让 `get_local_game_manifest` 从旧快照切换到新快照,并由非 Supervisor Agent 事件驱动资产、completed 任务、运行入口和版本卡出现;另测项目切换时旧请求迟到。旧的直接 `onManifestChange` 测试只能标记为下游桥接证据。 + +## React 资源详情焦点不能依赖重建对象身份(2026-08-05) + +- 现象:音频 / 视频播放器、文档链接或收起按钮正在获得焦点时,后台 manifest 更新会把焦点突然移回详情 region;若当前资源被删除,详情虽然消失,stale focused ID 和焦点可能残留到 `body`。 +- 原因:资源投影每次生成新对象,`useLayoutEffect([focusedResource])` 把同一资源的内容更新误判为重新进入详情;删除路径没有显式恢复状态和可聚焦 fallback,项目 / 运行视图切换也可能沿用旧 trigger。 +- 处理:焦点状态机只比较稳定 `resourceId`:`null -> id` 与 `idA -> idB` 聚焦详情,`idA -> idA` 保持当前 active element。显式收起 / Escape 才恢复原卡片与滚动;后台删除清理 focused / matching selected ID 并聚焦搜索框;项目或运行视图切换清空 trigger / restore。媒体预览副作用依赖稳定 ID、路径和类别,不因同 ID 对象重建先卸载控件。 +- 验证:媒体控件获得焦点后用同 ID 新 manifest 重渲染并断言 active element 不变;删除资源后断言详情关闭、选中清理且搜索框获得焦点;既有收起、Escape、项目切换和运行切换测试继续通过。 + ## 不要用自然语言精确 `.replace()` 维护 Runtime Prompt - 现象:Prompt 文案稍作改写、增删空格或调整段落后,替换静默失效,代码中出现难以审阅的链式 `.replace()`。 @@ -4099,6 +4145,20 @@ - 处理:使用完整 JSON Schema validator 校验原始 catalog schema,不手写 required/type 子集;native parser、fingerprint enrichment 与实际 MCP 调用边界复用同一校验器。enrichment 错误必须映射回 classified `arguments-schema` repair,不能以普通字符串直接终止 run;执行点重验用于阻断升级前已经落盘的 schema 外 pending。关闭网络和文件 `$ref` 解析,schema 无法安全编译时不广告或不执行。`serde` 类型错误会包含实际字符串值,catalog miss 也会包含模型提交的 server/tool,因此这两类错误同样只能返回稳定类别,不能拼接原始错误、参数值或 schema 内容。 - 验证:覆盖 required、additionalProperties、type、enum、本地 `$defs/$ref`、HTTP/file 外部引用、无效 schema、错误脱敏,证明 legacy wrapper 在注入 fingerprint 前进入 repair,并证明带旧有效 fingerprint 的历史 pending 在实际调用前仍被 schema 拒绝。 +## macOS 安全路径测试必须使用规范化临时目录(2026-08-05) + +- 现象:调用仓库上下文、Runtime context bundle 或 pending recovery 的 Rust 测试在 macOS 报“Repository root and its ancestors must not be symbolic links”,Linux CI 却可能通过;本地 HTTP 恢复夹具在完整串行测试中还可能偶发 `WouldBlock`。 +- 原因:`tempfile::tempdir()` 默认返回 `/var/folders/...`,而 macOS 的 `/var` 是指向 `/private/var` 的符号链接,生产安全校验会按设计拒绝该祖先;恢复测试的服务端读超时若仅为 2 秒,也会与完整测试负载下约 2 秒的首次请求形成窄竞态。 +- 处理:凡测试会进入仓库可信路径校验,统一使用 `crate::tests::canonical_test_tempdir(...)`,不得削弱生产符号链接拒绝规则;loopback 夹具保留有界超时,但为完整 CI 负载留足稳定裕量。 +- 验证:在 macOS 上定向运行 provider request、pending recovery、autonomous continuation 与 generation recovery 用例,再运行完整 `npm run check:native-shells`。 + +## Mach-O 文件头校验必须覆盖反字节序魔数(2026-08-05) + +- 现象:macOS arm64 的 Tauri release 已成功构建且 `file` 明确认定为 Mach-O,产物 staging 仍报“must be an executable Mach-O file”。 +- 原因:脚本用 `Buffer.readUInt32BE(0)` 读取文件头,却只比较 `0xfeedfacf` 等正序数值;arm64 常见头字节是 `cf fa ed fe`,读取结果为 `0xcffaedfe`。 +- 处理:文件头白名单同时覆盖 32/64 位与 fat Mach-O 的正序和反字节序合法魔数,并由桌面配置门禁同时反查 staging 脚本和根级产物检查,不能改成只按扩展名或构建退出码判断。 +- 验证:在 macOS 上构建真实 desktop-shell release,运行 `npm run desktop-shell:stage-release-binary`,再由 `npm run check:native-shells` 校验 staged 产物。 + ## 托管 MCP 新增公开域名时不能只更新网关路由(2026-08-05) - 现象:`https://dev.genarrative.world/api/external/v1/mcp` 的 manifest、OpenAPI 和 Bearer 鉴权都正常,但鉴权后的 `initialize` 返回 `403 FORBIDDEN`;通过 SSH 隧道访问同一 api-server 的 loopback 地址却可以正常列出 tools/resources。 @@ -4106,3 +4166,17 @@ - 处理:新增公开 MCP 环境时,同批登记对应 Host 与 HTTPS Origin;不要通过客户端伪造 `Host`、关闭防护或改走内部 SpacetimeDB MCP 规避。allowlist 变更属于 api-server 发布内容,必须随正常 API release 部署到目标环境。 - 验证:自动测试使用真实公开 Host/Origin 执行 `initialize`;部署后再从公网域名完成带 Key 的 `initialize`、`tools/list`、`resources/list`、Skill resource 读取和至少一个只读业务 tool 调用。loopback 成功只能证明 MCP 实现和 Key 可用,不能替代公网 Host 验收。 - 关联:`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 + +## GUI owner 锁不能替代逐 boot 的事件接收端登记(2026-08-05) + +- 现象:GUI 首次启动后 manifest 事件转发正常,但 Runner 被替换为新 boot 后只剩 owner 锁和 endpoint 可用,后台更新不再到达 GUI;或者 attach 响应只确认 owner,客户端却误记当前 boot 已完整登记,后续 ensure 不再重试。 +- 原因:把 OS owner 生命周期约束与进程内事件 sink attachment 混成同一状态,或在 `ensure_external_agent_runner` 之外执行一次性 attach;测试若用 actionId 等无关字段代替真实 sink port/token,也无法证明新 boot 重放的是可用接收端。 +- 处理:GUI 按规范化 AppData 私有登记真实 sink port/token,`ensure_external_agent_runner` 的 endpoint 复用和新 Runner 就绪两条成功路径都按 `bootId` 重放。同 boot 成功后幂等,新 boot 必须重挂;RPC、`attached` 或 `eventSinkAttached` 任一失败或缺失都不得记录成功 boot,并允许同 boot 后续重试。不同 AppData 不共享登记,未登记 CLI 不触发 attach;sink token 不进入日志、错误或公共状态。 +- 验证:分别覆盖真实 port/token 跨 boot 原样重放、同 boot 幂等、新 boot 重挂、普通 attach 失败、`eventSinkAttached` 缺失与 false 后同 boot 重试、AppData 隔离和未登记 CLI 零副作用。 + +## manifest relay 测试不能并行覆盖同一个全局 sink(2026-08-05) + +- 现象:crate 根 relay 测试在配置全局 sink 后阻塞等待 `TcpListener::accept()`,同时 Runner GUI owner attach 测试通过另一条路径覆盖并清空 sink;事件可能被发往另一端口,原 listener 随后永久等待。断言或 `expect` 提前失败时,成功路径末尾的手动 clear 也不会执行。 +- 原因:两个跨模块测试读写同一进程全局状态,却没有共用隔离边界;只给 accept 后取得的 stream 设置 read timeout 无法约束 accept 本身,payload 读取也缺少总 deadline。 +- 处理:全部全局 sink 测试共用一把 test-only 串行锁,并由 RAII guard 在 `Drop` 中无条件清空;测试统一使用 `manifest_invalidation_sink_isolation_` 前缀。relay fixture 对 accept 和 payload 分别使用非阻塞轮询与总 deadline,不使用固定 sleep;生产 loopback、token、连接 / 写入超时和 payload 大小校验保持不变。 +- 验证:用 `--test-threads=2` 重复运行统一 filter,覆盖正常 relay、无事件 accept 超时、不完整 payload 超时、panic 展开清理,以及 GUI owner attach 配置与 guard 清理。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3e08b0951..2e2053aee 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -80,6 +80,8 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod 2026-08-04 起,Runtime 的公共工具规划指令、Supervisor 协作编排 playbook、条件 overlay 和编译期静态 Agent 节点目录统一由版本化 Prompt Bundle 驱动,位于 `apps/ai-game-creator-shell/src-tauri/prompts/runtime/`。`manifest.json` 是 section 路径、组合顺序、平台 / Editor 变体、role overlay、Provider 协作 fragment,以及 Supervisor 与六组专业 Agent 静态目录的单一来源;role overlay 只允许 `rootSourceKind` 强类型语义 selector,构建期拒绝未知 kind,运行期把权威 source 常量映射为生成 kind。`build.rs` 同时监听 Bundle 每一级目录、manifest 和已登记 section,保证任意嵌套目录新增孤立 Markdown 都会触发增量构建,并以失败关闭方式校验 schema、引用、路径 / symlink、孤立 Markdown、selector、节点身份、旧 alias 和生成标识符,再生成 `'static + Copy` Rust 定义并编译进发布二进制。公共 runtime system header 保持身份中立;生成的 Supervisor planning composition 必须复用 `supervisorChat.identity`。每个 section 只能属于 runtime composition、Supervisor composition、chat 字段、platform variant、visual variant、role overlay 或 Provider fragment 中一个语义所有者;唯一例外是同一 identity section 由 Supervisor planning 与 `supervisorChat.identity` 显式复用,从而同时阻断 Supervisor 指令外泄和动态 variant 与静态 composition 的重复注入。专业节点 taskId / group / role 还必须在构建期与 `shared-contracts::new_game_creation_app_seed_tasks()` 强一致,防止身份合同、静态目录和正式 seed DAG 漂移。Bundle 承载公共指令、隔离 Agent 合同、平台差异、角色选择、并行委派、all-join、视觉返工、claim gate,Supervisor 共享核心身份、interaction / final-reply 专属合同及其组合,以及首批协作、delivery 收敛、manifest wait、试玩后续委派等 repair 自然语言合同;background planning 在 system composition 复用核心身份,所有 user context 都不再重复注入 Supervisor 身份合同正文。`agent_runtime_native_executable_tools()` 仍是原生可执行工具的权威源列表,同时供 Prompt 工具目录与 native capability registry 使用,MCP 工具只从当前请求的动态 catalog 暴露。最终 Provider 请求必须通过生成的 section、composition、overlay 与 provider fragment API 构建,禁止恢复直接 `include_str!("prompts/runtime/...")`、在 Provider 或 `prompt.rs` 源码中复制协作 graph 文案,或依赖自然语言精确 `.replace()` 注入工具合同、平台规则或角色规则。Bundle 不是完整可执行 graph:正式 DAG 依赖边、权限、沙箱、委派容量、持久 all-join 状态机、完成门和身份校验仍由 Rust、`shared-contracts` 与经校验的 `.agent/collaboration-policy.json` 强制执行,不允许通过 Skill、外部配置或任意运行时 Prompt 覆盖绕过。 +Prompt 静态门禁必须断言上述 Bundle section 当前定义的权威语义与组合关系;身份文案调整后应同步更新旧断言,不得继续依赖已经退出 Bundle 的历史连续措辞,也不得在测试或 Provider builder 中复制一份平行 Prompt。 + 2026-07-12 起,通用开发能力的 Runtime V1.1 增量以 [`【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`](./【技术方案】AI游戏创作Agent%20Runtime%20V1.1-2026-07-12.md) 为编码级事实源。它补充仓库启动上下文、同一发布二进制独立 Runner、受限本地预览浏览器验证、动态隔离子 Agent 和真实 Provider 全链路验收;本文件中“进程内 tokio task”“首轮不预加载项目内容”和“不创建动态执行实例”的旧口径由 V1.1 明确替代,未涉及能力继续沿用本文件。 同一文档的“V1.2 对标 Codex CLI 增量”继续作为受控命令与推理档位的事实源。对一次性 `command.exec` 而言,只接受 Runtime 白名单内的固定 `program` 和逐项 `args` argv,默认 `confirm`,可执行文件解析为项目外绝对路径且子进程只使用安全 PATH;不解析 shell 字符串,不提供管道、重定向、PTY 或后台进程。这里对 PTY 和后台进程的排除仅适用于 `command.exec`,不能用来否定 V1.10 的独立持久进程工具,也不能把 `command.exec` 自身改成长驻入口。`command.exec` 的 action、stdout / stderr、退出码、超时与源码指纹结果统一进入现有 `action / observation`、project revision、verification gate 和 `needs-reconciliation` 链路;只有明确验证型命令且退出码、源码指纹、命令日志、manifest 与 Agent DB 审计全通过才签发 passed gate,Git / rg / cargo metadata / 普通 npm run 只作诊断。首版只请求终止受控进程组,安全等级与 `project.verify` 相同,不宣称已具备完整 OS sandbox 或 detached-process 隔离。 @@ -156,7 +158,7 @@ V1.17 计划快照随 `game-creator-runtime-context-bundle.v3` 持久化,v2 2026-07-19 起,当前父 run 下的专业 Agent 进入 `failed` 后,正式工作台必须提供“在当前项目重试”恢复入口,不得要求用户新建项目。重试必须精确核对原 `agentId + runId + parentRunId`,复用原 task、active Session 和父 run 归属,同时生成新的专业 Agent runId;新 run 继承已持久化的上下文和父子绑定,不覆写旧失败 run 的审计事实,也不得把 UI 重试解释为底层 transport 根因已修复。`agent.resume` 默认 `confirm` 不变:自动 retry command 继续执行 auto gate;正式失败卡按钮自身是本次明确确认,使用 deny-only 的 confirmed retry command。按钮必须原卡即时显示“正在提交重试”、受理或安全错误;若 Supervisor 已为同一 delegation 准备合同 repair,则该按钮优先确认既有 repair,避免重复派发。 -completed 专业 Agent 的用户可见成果不能仅依赖项目文件。当委派合同为 `expectedArtifacts=[]` 或未产生显式文件时,工作台必须读取该 Agent 持久对话的最后一条 assistant,作为明确标注的“专业 Agent 文本回执”提供查看,并投影到“资源管理 → 文档”。该投影是持久回执的可见视图,必须保留来源 Agent 与 run 身份,不冒充 manifest asset、项目目录中的实际文件或可下载交付物;内部 Agent ID 不进入用户资源名。美术只交付计划且 manifest 没有图片时必须显示“仅完成计划,尚未生成或登记图片”,不能把文本回执称为美术图片产物。策划 `design-foundation` 与美术 `art-asset-plan` 从 2026-07-20 起属于图片产物型 canonical task:前者必须生成并登记 `assets/ui-prototype.png` 横屏界面原型图,后者必须生成并登记 `assets/art-spritesheet.png` 首版核心美术素材;只有文本计划或空 `expectedArtifacts` 不构成完成。策划图还必须由 `design-foundation` 的 `image.inspect` 对当前图片 SHA 形成 `ui-prototype.v2` 结构化视觉验收:信息 HUD、主要可玩区域、当前玩法的关键实体、主要操作、失败/重开流程、移动端布局意图、实现清晰度与原创主题八项全部通过且问题列表为空。验收不得预设塔防或任何固定玩法;`ui-prototype.v1` 仅供历史动作回执安全读取,不能作为新建或恢复 run 的 completion authority。Runtime finalization 只接受同 run、当前 SHA 的 v2 证据。视觉调用失败、响应不可解析、检查未通过或图片被替换后 SHA 不一致都继续阻塞。未通过的已登记图片在工作台只称“候选界面图(待视觉验收)”,不得冒充正式 UI 原型。图片生成未配置、待确认或失败时保持阻塞/失败,不得投影为 completed。资源卡允许同分类内做当前会话拖拽重排;详情使用受 viewport 与底部 dock 约束的独立可拖浮层,长正文由唯一外层滚动容器承载并用 Markdown 安全渲染。PDF 方案外的顶部项目标题条移除,dock 下方不得保留空白;底部专业 Agent 为只读状态卡,不得因点击保留多个详情浮层。 +completed 专业 Agent 的用户可见成果不能仅依赖项目文件。当委派合同为 `expectedArtifacts=[]` 或未产生显式文件时,工作台必须读取该 Agent 持久对话的最后一条 assistant,作为明确标注的“专业 Agent 文本回执”提供查看,并投影到“资源管理 → 文档”。该投影是持久回执的可见视图,必须保留来源 Agent 与 run 身份,不冒充 manifest asset、项目目录中的实际文件或可下载交付物;内部 Agent ID 不进入用户资源名。美术只交付计划且 manifest 没有图片时必须显示“仅完成计划,尚未生成或登记图片”,不能把文本回执称为美术图片产物。策划 `design-foundation` 与美术 `art-asset-plan` 从 2026-07-20 起属于图片产物型 canonical task:前者必须生成并登记 `assets/ui-prototype.png` 横屏界面原型图,后者必须生成并登记 `assets/art-spritesheet.png` 首版核心美术素材;只有文本计划或空 `expectedArtifacts` 不构成完成。策划图还必须由 `design-foundation` 的 `image.inspect` 对当前图片 SHA 形成 `ui-prototype.v2` 结构化视觉验收:信息 HUD、主要可玩区域、当前玩法的关键实体、主要操作、失败/重开流程、移动端布局意图、实现清晰度与原创主题八项全部通过且问题列表为空。验收不得预设塔防或任何固定玩法;`ui-prototype.v1` 仅供历史动作回执安全读取,不能作为新建或恢复 run 的 completion authority。Runtime finalization 只接受同 run、当前 SHA 的 v2 证据。视觉调用失败、响应不可解析、检查未通过或图片被替换后 SHA 不一致都继续阻塞。未通过的已登记图片在工作台只称“候选界面图(待视觉验收)”,不得冒充正式 UI 原型。图片生成未配置、待确认或失败时保持阻塞/失败,不得投影为 completed。资源卡当前只允许点击聚焦,不提供拖动重排;详情使用中央主视窗内唯一聚焦状态,长正文在聚焦主体内独立滚动并用 Markdown 安全渲染。PDF 方案外的顶部项目标题条移除,右侧对话和底部 Agent dock 常驻且 dock 下方不得保留空白;底部专业 Agent 为只读状态卡。 V1.17 同时把 finalization journal 升级为 v2 并绑定最终完整计划快照:assistant 已落盘而 Runtime state 丢失时,从 v2 journal 恢复原结构化计划后补齐终态;assistant 尚未落盘且 state 丢失时失败关闭。外层 `failed / budget-exhausted` 只保留最后可信计划,不把未完成步骤机械改成失败。thinking summary、legacy plan event 和 tool-plan repair 公共审计只保留哈希、字符数或计数,必要的模型输出与错误上下文仅留在有界私有 repair 请求中。 @@ -272,6 +274,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。 - 2026-07-10 补充:`agent.delegate` 已形成可恢复的父子任务闭环。`delegationId` 由 durable pending action 的 `actionId` 派生,子任务记录会保存 `parentAgentId / parentRunId / delegationId`,终态记录额外保存经过统一凭据清洗和安全截断的 `terminalDetail`;同一委派的提交和回执分别受 delegation 级 OS 文件锁保护,同一目标 Agent 的 runId 分配与 pending 追加还受任务账本 OS 锁保护。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态时,Runtime 按 `delegationId` 幂等生成且至多生成一次 `agent.delegate.result` 回执,失败、排队或活跃取消、预算耗尽都必须回传,不能只覆盖成功。回执会向父 Agent 既有队列追加固定 runId、`source=agent-delegate-receipt` 的续跑任务,把完整的已清洗 `terminalDetail` 交回父 run,不再只保留 80 字符 UI 摘要;回执 prompt 明确禁止重复同一委派,排队期间不提前写入父会话,真正开始执行时才幂等落盘,用户消息或回执消息落盘失败时不会进入 LLM。回执任务保留父 run 关联,并在真正开始或恢复前再次检查父 run 状态,关联缺失或父 run 不存在时失败关闭;该续跑仍受父 Agent 原有 FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障约束,不直接重入父 run、不插队、不新增独立 worker;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活,父 Session 归档与切换会被未结束委派阻止,极端归档竞态下回执回落到父 Agent 当前可写 Session。恢复先恢复 pending action / reconciliation 屏障,再扫描“子任务终态已落盘但回执未提交”的窗口并补齐缺失回执;`needs-reconciliation` 本身不回执,只有人工核对后最终取消才回传 `cancelled`。 - 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach owner;Runner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。GUI 客户端还必须把完整 `runner.attach_gui_owner` 参数作为绑定规范化 AppData 的进程内登记保存;`ensure_external_agent_runner` 无论复用既有 endpoint 还是启动新 Runner,都要在把 endpoint 交给 Runtime 写请求前按新 `bootId` 补登记。同一登记 generation 在同一 boot 上幂等,补登记失败不得记录成功 boot 且本次 `ensure` 失败关闭;未建立 GUI 登记的普通 CLI 不执行该重放。OS owner 锁与 watchdog 已成立只代表进程受 GUI 生命周期约束,不能替代事件 sink 等进程内附加能力的逐 boot 恢复。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`。 +- 2026-08-05 GUI owner attachment 确认补充:登记参数必须保存 GUI manifest 事件接收端的真实 `event_sink_port` 与 `event_sink_token`,不得借用 actionId 等无关字段作为测试替身。每次 attach RPC 只有同时返回 `attached=true` 与 `eventSinkAttached=true` 才能把当前 `bootId` 标记为已登记;`eventSinkAttached` 缺失、为 false 或普通 RPC 失败都保持当前 boot 待重试。sink token 只留在私有进程内登记和 RPC 参数中,不进入日志、错误文本或公共状态。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。只有开发窗口的专业 Agent 前台直调可使用对应角色上下文;正式用户前台现已统一进入 `project-supervisor`。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:同一 Agent 的开发前台直调、流式调试和后台任务统一使用 `.agent/runtime/locks/.lock` OS 文件锁。开发前台不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在开发前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的调试结果改判为失败。正式用户 GUI 不通过该入口直聊专业 Agent;不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。 - 2026-07-10 补充,2026-08-01 更新:默认 `agent.resume=confirm` 时,客户端自动恢复命令先做只读 recovery preflight。全新项目和已完全终态且没有 task / retry / handoff / finalization / pending action / reconciliation 等 durable recovery work 的项目直接返回空结果,不显示虚假的 `agent.resume` 确认条。确实存在可恢复工作时,自动命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。 @@ -352,7 +355,8 @@ game-project/ - `canvas.project_sync` 复用 Genarrative External Editor API,读取用户平台 API Key 可访问的画板项目快照,通过 `/api/external/v1/assets/read-url` 换签并把资源下载到本地项目 `assets/canvas-sync/`;默认 API base URL 为 `http://127.0.0.1:8082`,可用 Tauri 应用配置目录中的 `game-creator.config.json` 的 `editorApi.baseUrl` 覆盖,API Key 从同一配置的 `editorApi.apiKey` 读取,不写入项目文件、trace、manifest 或日志。 - Agent loop 中美术组 `Asset` 和音乐组 `SFX` 会读取 `.agent/manifest.json`;图片生成先通过 External Editor API 项目与素材库接口准备同名画布会话,再带稳定 `Idempotency-Key` 调用生成端点。Runtime 在私有生成账本持久化精确请求、幂等键和返回的 `operationId`,并按 `pollAfterMs` 查询统一状态端点;completed 后从 compact result 取得稳定 objectKey/resourceId,再通过 `/api/external/v1/assets/read-url` 换签下载到受控本地 `assets/` 路径,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 不写入项目文件;幂等键只作为该动作的私有可恢复身份保存,不进入 agent.db、trace、manifest、observation 或日志;operationId 允许出现在脱敏的对账错误与私有账本中,但不进入 manifest。未配置 Key、查询 failed 或 compact result 缺少稳定媒体引用时,图片产物型任务保持阻塞/失败,不能以文字计划完成。音乐组仍只建议同步已有音频资源,不调用图片生成接口。 - `canvas.asset_import` 当前作为最小真实链路:导入项目目录内已有文件为 `canvas` 来源资产,并要求记录画板项目 ID 以及 resourceId 或 assetObjectId。 -- 项目工作台点击已登记图片时必须在客户端资源浮层中直接渲染图片,而不是只展示路径与 MIME。图片通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验后以 data URL 返回;首版只支持 PNG、JPEG、WEBP,不向 WebView 暴露任意本机文件协议或绝对路径。 +- 项目工作台点击已登记图片时必须在中央主视窗的资源聚焦状态中直接渲染图片,而不是只展示路径与 MIME。图片通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验后以 data URL 返回;首版只支持 PNG、JPEG、WEBP,不向 WebView 暴露任意本机文件协议或绝对路径。 +- 2026-08-03 阶段四在上述图片链路外新增 `read_local_project_text_preview` 与 `read_local_project_media_preview`。前者只接收当前 manifest 已登记文档或已完成任务中的 Markdown / 文本 / JSON / YAML / TOML,限制 2 MiB 与 UTF-8;Agent 文本回执继续直接消费合法对话投影,不反查本地路径。后者的美术分支接收 GIF、安全 SVG、AVIF、BMP、MP4、WebM、MOV,音频分支只接收 manifest 已登记的 MP3、WAV、OGG / Opus、M4A、AAC、FLAC,二进制媒体限制 32 MiB。两条命令统一执行 `file.read` auto 权限、规范化相对路径、项目边界、敏感路径、普通文件、父目录链接、硬链接、读取漂移和重开身份复核;媒体按文件签名而非只按扩展名或 MIME 建立 data URL,SVG 额外拒绝活动内容与外部引用。 - `canvas.export_import` 复用 `/editor/canvas` 已有素材导出 ZIP 格式,读取根 `metadata.json`、复制 `images/` / `media/` / `sequences/` 到本地项目 `assets/canvas-imports/`,再按导出层登记为 `canvas` 来源资产;导出包不保存真实 resourceId 时,使用 `canvas-export:` 作为可追踪 assetObjectId,不伪造后端资源行。 ## GameAgent V1.0 项目开发工作台首版界面 @@ -363,8 +367,8 @@ game-project/ - 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。 - 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。 -- 资源管理从当前 `GameCreationAppManifest` 派生项目文档、项目版本和 `assets`,并把首页已导入附件作为当前项目上传资源展示。资源按文档、版本、美术、动作、音乐音效分区;`按依赖 / 按类型` 只改变当前前端排列方式,不写回 manifest,也不伪造资源依赖。 -- 资源卡支持选择聚焦、文档展开 / 收起、搜索和类型筛选的界面交互。2026-07-28 起,原一维会话拖拽已替换为两套二维坐标与本地 CAS sidecar;画板编辑、生成关系连线、同类型版本资源替换仍不得在缺少各自正式写回契约时保存为业务事实。 +- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。 +- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。 - 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并展示上一项 / 暂停继续 / 下一项切片控制、素材信息和数值微调面板。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;切片、参数调整和自然语言新增调节项首版仍只保留本地 UI 草稿,不修改代码或 manifest。 - 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。 - 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。 @@ -373,27 +377,52 @@ game-project/ - 当前独立 App 只交付横屏桌面工作台,Tauri `client` 默认与最小窗口统一为 `1280×800`;用户不能继续缩小到破坏双栏结构的窄屏尺寸。桌面工作台占满壳内剩余视口,四周只保留必要安全边距;右侧消息、Runtime 状态和输入区保持在同一栏内,专业状态过长时只滚动 Runtime 区,不得把输入区、底部 Agent 状态栏或整页撑出视口。`≤760px` 的浏览器样式仅保留开发兼容,不作为当前客户端交付口径。 - 项目总控失败摘要必须提供“在当前项目重试总控”的明确恢复动作并说明不会新建项目;旧父 run 下仍在运行的专业 Agent 继续展示真实状态。terminal 总控下不得单独重试专业 Agent,避免创建没有可交付父级的孤立委派;新总控 run 负责重新建立后续专业委派合同。 - 外部 Runner 模式下,重试命令的 Session Runtime 快照可能仍指向旧 run,因此响应必须额外返回精确 `acceptedRunId` 作为入队受理事实,前端据此锁定恢复按钮并持续同步该 run,不能用 `state.runId` 是否立即切换判断失败。同一 `agentId + sourceRunId` 已存在非终态 retry successor 时必须幂等复用并返回其 `acceptedRunId`,不得再次入队或追加第二条 retry audit。 -- 该界面切片只允许受限的 loopback iframe,不得引入远程 URL、第二套资产模型、前端正式资源关系、前端版本替换真相或前端计费结论。 +- 该界面切片只允许受限的 loopback iframe,不得引入远程 URL、第二套资产模型、前端版本替换真相或前端计费结论。资源关系图只能读取当前 manifest 与资源投影做派生展示,不得成为前端正式资源关系真相。 ### 资源画布布局持久化 V1 2026-07-28 起,资源画布布局持久化以工作台 PRD §5.2 和 §7.2 为唯一编码合同,实施边界如下: +2026-08-03 需求收口:资源卡手动拖动暂缓。历史 sidecar 坐标继续只读恢复,自动布局仍可为资源集合协调提交 CAS;sidecar schema、布局读取、FIFO、跨窗口系统锁和 Rust CAS 基础设施全部保留,但当前没有用户手动布局入口。 + - dependency 与 type 分别保存到 `.agent/workbench/resource-layouts/dependency.json` 和 `type.json`,schema 固定为 `game-creator-resource-layout.v1`。布局是本地工作台 UI sidecar,不进入 manifest、游戏项目 mutation revision、Runtime verification、Agent 产物、资产或云端事实。 - `x / y` 使用 section 内容坐标,`updatedAt` 使用 Unix 毫秒;文件缺失只合成 revision `0` 空布局且不产生只读副作用。每个 mode 按 `projectId + mode + expectedRevision` 做 CAS,成功 revision 加一,冲突返回最新完整布局且不写文件。revision 虽在 Rust 中使用 `u64`,但跨 JSON / Tauri / TypeScript 的合法域固定为 `0..=Number.MAX_SAFE_INTEGER`;共享 DTO 序列化与反序列化、Tauri 输入和前端 IPC 响应均执行同一边界校验。 - Tauri 命令固定为 `read_local_project_resource_canvas_layout` 与 `update_local_project_resource_canvas_layout`。写命令先只读确认有效 manifest,再通过持久 `.layout.lock` 入口获取句柄级跨窗口系统锁,并在锁内复核 projectId、重新读取当前 sidecar。Unix 使用 `flock`,Windows 使用不共享文件句柄;释放只通过句柄 Drop / 进程退出完成,不使用 mtime stale 回收,也不删除锁文件。其余写入继续复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。 -- 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;单窗口写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。视图使用 Pointer Events 做二维拖动,保存中仍允许继续拖动并排队,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。 +- 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;自动协调写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。资源卡是普通可点击按钮,不绑定卡片级 Pointer 拖动处理器;指针移动不修改 CSS 坐标、不调用 SVG preview、不提交手动布局 CAS。 - 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。 - type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序。manifest 资产的 subtype 使用 `asset.kind`,任务产物、导入附件和 Agent 文本成果使用稳定的来源 fallback;subtype 必须进入资源协调签名,不能因 MIME 相同而退化成按名称混排。 -- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,同时清除基于旧快照排队的全部手动意图,不自动重放旧坐标。即使冲突发生在允许自动重试的资源协调请求上,只要本次冲突清除了排队手动意图,重新拖动提示就必须绑定当前 scope 保留,不得被后续资源协调成功、失败或通用提示定时器静默清除;新的手动布局成功保存或 scope 切换后才解除。资源自动协调可基于冲突布局最多追加两次重试,持续跨窗口竞争时停止自旋并保留当前会话协调结果。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。 -- 本切片不包含资源关系线、资源替换、详情浮层位置、缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。关系线与其它 P1 能力必须在本切片独立验收后继续接入。 +- 自动协调保存失败时保留当前会话布局;CAS 冲突载入对方最新布局,需要继续协调时最多追加两次重试,持续跨窗口竞争时停止自旋。用户提示只说明“布局已在其他窗口更新”,不要求重新拖动。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。 +- 本布局持久化切片不包含资源关系线、资源替换、聚焦态持久化、缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。资源关系线与当前会话内中央聚焦已在后续独立前端切片接入,不改变本段 sidecar 合同;其余 P1 能力继续独立实施。 -实施顺序固定为:先同步 TypeScript / Rust DTO 与序列化测试,再实现 Tauri sidecar 读写和 CAS,随后接入前端纯模型、持久 Hook 与二维拖动,最后完成 Rust 安全测试、React 交互测试、跨重启 / 双窗口验收和文档状态回写。任何一步不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。 +历史实施顺序已完成 TypeScript / Rust DTO、Tauri sidecar/CAS、前端纯模型与持久 Hook。二维手动拖动接线现已暂缓;重新开放前必须先更新 PRD 与验收合同。任何后续步骤不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。 -2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + scope 内单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 不等待旧 scope 卡死请求、持续冲突有界停止,以及在途 CAS 后同资源连续拖动折叠为最后坐标。旧 scope 请求已经发出后不做不安全取消,但会释放前端活动槽并由 epoch 丢弃迟到响应;同 scope 的在途请求仍保持唯一。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现。 +2026-07-30 前端并发与性能加固形成的 scope epoch、scope 内单写者 FIFO、手动意图折叠和冲突恢复实现继续保留为底层技术资产;当前用户入口只消费布局读取与资源自动协调。默认布局仍使用 section 分组与二维占用索引,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式自动布局性能回归继续防止恢复到接近 `O(N³)` 的主线程阻塞实现。手动拖动帧预算、局部 preview 性能和重新拖动提示不再是当前验收条件。 2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令携带只用于校验的 `expectedProjectId`,在任何目录创建前先读取 manifest 并拒绝旧项目窗口,锁内再次核对 projectId;不存在根、非项目根、损坏 manifest 和路径重建后的旧窗口均不产生 `.agent/workbench`。revision 在共享 serde、Tauri 命令和前端 IPC 三层限制到 `Number.MAX_SAFE_INTEGER`,达到上限时保持原文件并失败关闭,不能让 Rust `u64` 值在 JavaScript 中失真后击穿 CAS。 +### 资源依赖关系图层 V1.1 + +2026-07-31 起,项目工作台使用“Tauri Rust 只读拓扑 + 前端原生 SVG 几何”的资源依赖图层;不修改 layout sidecar、既有布局模型、api-server 或 SpacetimeDB: + +- `read_local_project_resource_graph` 读取当前 manifest、前端资源卡身份列表和最多 `32 MiB` 的安全 Agent DB 尾部,通过 Rust 构建稳定 read model;读取使用既有 Agent DB 普通文件 / 链接 / 追加锁边界,不新增数据库或 sidecar。返回资源 ID、引用边、聚合任务流、producer assignment、独立 `dependencyDepths`、循环集合、unresolved 外部 ID、局部连接索引和 `producerMappingTruncated`。 +- 精确引用把 manifest 资产 `source.referenceResourceIds` 唯一匹配到另一资产的 `source.resourceId`,再转换为本次资源卡 ID;无匹配、多匹配、重复卡片或已删除资源只记录为 unresolved / 忽略,不生成边。引用边按 `sourceResourceId + targetResourceId` 稳定去重;前端 `resourceDependencyGraphModel.ts` 再做一次 DTO 端点防御过滤,避免异步切项目时出现幽灵线。 +- task flow 只读取存在于当前 manifest 的任务依赖。画布资产 producer 仅接受 `agent.runtime.canvas.asset_generate` 中经 manifest 校验的 `assetId -> agentId`;External Editor 返回并保存在 `source.taskId` 的 `task-1` 等身份属于平台生成任务,禁止复用为 manifest task。多个有效 Agent 对同一资产形成冲突或证据缺失时,不生成该资产对应 task flow。任务产物 / Agent 回执继续使用资源投影中已有的 manifest task 身份。 +- 资源按可信 producer 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow;SVG 侧绘制 source 分支、唯一主线和 target 分支,路径数量为 `O(S+T)`,禁止资源笛卡尔积。局部连接索引保存 resource 关联的 reference edge ID / task flow ID,不预先展开 `S×T` 邻接矩阵。 +- 资源引用图和完整任务依赖图在 Rust 分别使用迭代式强连通分量分析。任务环检测不能依赖可视 task flow 是否有两端资源,否则无产物任务参与的环会漏报;循环边只带 cyclic 标记,不触发递归展开。 +- `ResourceDependencyOverlay.tsx` 使用原生 SVG path/marker,绝对定位在 `.game-resource-canvas-content` 底层并统一 `pointer-events: none`。橙色实线表示 `asset-reference`,灰色圆头虚线表示 `task-flow`;两类连线统一使用连续贝塞尔曲线,任务主线略强于两端分支,箭头使用不随高亮线宽缩放的稳定用户空间尺寸,避免直角折线、突兀拐弯和箭头跳变。不引入 D3、React Flow 或其它图表依赖。 +- `asset-reference` 的 source / target 是同一资源时使用卡片右侧外绕贝塞尔闭环,两个锚点分开且 marker 保留在返回锚点;路径不穿过卡片。dependency section 在现有 extent 外额外增加 `64px` 右侧视觉 gutter,确保最右卡片的闭环和箭头可滚动显示;不改卡片坐标、`resourceCanvasLayoutModel.ts` 或 sidecar。 +- 图层用 SVG 自身节点定位所属画布容器,测量各 section plane 相对原点;`ResizeObserver` 在图层挂载时只创建一次,与 window resize 一起负责重新测量并在卸载时清理。type 模式不挂载图层且释放 graph state;项目身份作为 key,切换 mode、项目或工作台卸载都会销毁旧 SVG。 +- 基础 positions 保持稳定;卡片 Pointer Move 不进入 SVG preview,只有搜索、选择、项目切换、真实 positions 或 section origin 变化才重新协调图层。SVG 几何从不持久化。 +- Rust、前端 DTO/SVG 和工作台 AppSurface 回归覆盖生产 `task-1` 数据形状、真实 producer、证据缺失、精确引用、去重、无效 ID、完整任务环、4096 链式拓扑、聚合复杂度、搜索过滤、选择高亮、稳定 Observer、type 模式卸载与项目切换销毁。局部拖动更新与真实 Chromium 拖动性能目标暂缓。 + +2026-08-03 阶段五加固,2026-08-05 明确截断信任边界:Rust read model 把 producer assignment 与布局深度分离。完整任务图先经 SCC 压缩形成可信 producer 的任务深度下限,精确资源引用图再经迭代式 SCC 压缩和确定性最长层级传播形成所有可见资源的 `dependencyDepths`;因此同一任务的派生资源、缺少 producer 审计的 manifest 资源和引用环都能稳定满足“被引用资源在前、引用资源在后”,没有引用关系的资源保持深度 `0`。`producerMappingTruncated=true` 只表示有界 Agent DB 尾部不足以证明 producer:前端必须失败关闭 `producerAssignments`、`taskFlows` 及其 `cyclicTaskIds`,但继续严格校验并消费 Rust 从当前 manifest、精确引用和仍可信下限构建的 `dependencyDepths`;`referenceEdges`、connection index 中的 reference 关系、`cyclicResourceIds` 和 unresolved reference 同样继续有效。前端不递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。任务流仍按任务对聚合,不为布局计算或 SVG 绘制生成资源笛卡尔积。 + +2026-08-03 阶段六:正式迭代版本直接扩展本地 `.agent/manifest.json`,不新增 checkpoint / layout sidecar / SpacetimeDB 平行业务真相。共享 Rust / TypeScript 合同新增可选 `versions: GameIterationVersion[]`;旧项目缺失字段时只读为空,不回填。Rust 在 manifest 读写边界校验版本唯一性、父先于子、根/原因一致、父子修订与时间单调、slot 唯一和 JavaScript 安全整数,并在覆盖已有 manifest 前要求磁盘版本数组是新数组的逐项相等前缀,从存储边界保证历史记录不可修改、删除或重排。 + +工作台资源投影只从 `manifest.versions` 构建版本卡,按数组追加顺序生成稳定“版本 N”标题;不再接收前端独立 `projectVersions` 注入。`resourceBindings.resourceId` 只解释为 manifest asset ID,并映射到现有 `asset:` 卡片。选中版本后在 dependency / type 两种布局中高亮当前仍存在的绑定资产;缺失历史资产只留在版本聚焦详情,不能合成幽灵卡或猜测 External Editor resource ID。版本聚焦复用中央只读容器,展示身份、修订、原因、父版本、直接子版本、创建时间与 slot 绑定。本阶段不提供版本创建、替换、切换、回滚、测试切片或运行态消费入口。 + +历史命令式 drag preview 句柄与局部连接索引可以保留,但项目工作台不再向资源卡传入该入口。拖动热路径、4096 张真实卡片拖动重渲染和 Chromium p95 门槛统一暂缓;当前回归只要求 Pointer Move 不改变卡片坐标、SVG path 或布局 revision。`ResizeObserver` 仍保持单图层单实例,任何实时 DOM 几何都不得通过 Tauri IPC 往返 Rust。 + ## 分阶段实施 1. 在 `platform-agent` 建立游戏创作专业组与种子任务图契约。 @@ -406,7 +435,7 @@ game-project/ - 用户能创建本地 Web 游戏项目。 - 用户进入项目开发页后能看到资源管理主视窗、陶泥儿对话栏和底部策划 / 美术 / 程序 Agent 状态栏;`1280×800` 最小横屏窗口和更大桌面窗口均不得出现页面级横向 / 纵向溢出,对话输入与底部 Agent 状态栏始终位于视口内。 -- 资源管理可在按依赖 / 按类型之间切换、搜索资源、展开文档和聚焦资源;所有展示数据来自当前 manifest 或当前项目导入附件。 +- 资源管理可在按依赖 / 按类型之间切换、搜索资源并点击打开当前资源详情;dependency 模式展示可验证的资源引用和聚合任务流,搜索过滤端点、选择高亮直接上下游。资源卡不可拖动,Pointer Move 不更新坐标、线段或手动布局;所有展示数据来自当前 manifest、当前资源投影或当前项目导入附件。 - 首个 `code-prototype` 任务未完成时运行入口不可进入并给出可感知提示;完成后可进入运行表现层,真实预览直接加载到客户端内受限运行容器。 - 审批档位通过独立弹出面板切换,默认严格审批;界面选择不得绕过 Runtime 现有确认门禁。 - 聊天输入 `/plan` 可在普通聊天消息里查看下一轮分工计划,不读取任务文件、不启动 run、不修改项目,也不新增普通用户计划面板。 @@ -475,6 +504,7 @@ game-project/ ## v1 验收证据矩阵 +- 2026-08-03 资源管理阶段七收口:阶段零至阶段六已逐项对照飞书需求、当前 PRD、实现、测试与提交证据,剩余工作仅为完整验收。AppSurface 新增原生视频 `controls + preload="metadata"` 与资源读取策略失败安全空态回归,并确认失败时右侧 Project Supervisor 对话和底部 Agent Dock 不被中央主视窗替换;全量 Vitest 为 `171` 个文件、`2155 passed / 5 skipped`。应用内浏览器固定 `1280×800` 后,`window`、document 与 body 的 client / scroll 宽高均为 `1280×800`,未出现页面级溢出;开发页真实登录门禁未被绕过,工作台内部结构继续由 AppSurface 和 CSS 合同测试作为可重复证据。最终门禁还包括 lint、build、Rust workspace test / check、SpacetimeDB schema、原生壳、内容 / 编码和生产运维检查;本地 `.env*` 不进入提交。 - `npm run ai-game-creator-shell:check`:覆盖壳 typecheck、聊天命令单测、用户 / 开发窗口 UI 边界 smoke、主窗口命令按钮复用 `/help` 命令列表、主窗口项目摘要从 manifest / trace 派生任务完成数、ready 数、资产来源分布和最近命令且未选项目时不显示、灵感草稿只填充输入框不提交、能力按钮复用 `/capabilities`、LLM状态按钮复用 `/llm-status` 且结果回填 Agent 状态列表、聊天侧 `/agents` 汇总和单 Agent 对话的 provider / 模型 / 流式 / API Key 读取状态、开发日志面板只读读取 `.agent/logs/command.log` / `preview.log` / `agent.log`、项目状态按钮复用 `/status`、权限按钮复用 `/policy` 且策略草稿按钮只填入 `/policy-confirm project.index` / `/policy-confirm asset.register` / `/policy-confirm memory.write` / `/policy-confirm preview.start` / `/policy-confirm preview.open` / `/policy-confirm preview.stop` / `/policy-confirm agent.run_status` / `/policy-confirm conversation.read` / `/policy-confirm conversation.write`、审计按钮复用 `/audit`、资产按钮复用 `/assets` 且资产结果可一键复用 `/read`、任务按钮复用 `/tasks`、聊天侧 `/agents` 汇总每个 Agent 的当前状态、聊天侧 `/agent-conversations` 列出 Agent 对话读取命令、聊天侧 `/agent-memories` 列出 Agent 私有记忆读取命令、Trace 按钮复用 `/trace`、文件按钮复用 `/files` 且文件结果可一键复用 `/read`、索引按钮复用 `/index`、记忆 / 短期记忆 / 黑板按钮复用 `/memory long|short|blackboard`、快照按钮复用 `/checkpoint`、快照列表按钮复用 `/checkpoints` 且 checkpoint 结果可一键复用 `/diff` / `/restore`、历史按钮复用 `/history`、受限命令白名单按钮复用 `/commands` 且无需项目初始化、静态自检快捷按钮复用 `/smoke`、预览状态快捷按钮复用 `/preview-status`、主窗口运行时配置面板读写 Tauri 配置目录中的 `game-creator.config.json`、支持全局与每个 agent 单独选择 LLM Provider 且不把 API Key 写入聊天、单 Agent 对话面板可手动追加私有记忆且走 `memory.write` 策略、主窗口提供音效登记、画板音频导入和常用生成产物读取草稿入口,聊天侧 `/art` 可盘点美术素材且不直接触发平台生成或画板同步,聊天侧 `/context` 可盘点生成上下文来源且不直接读取上下文文件,聊天侧 `/timeline` 可汇总项目活动时间线且不直接读取日志或 trace 文件,聊天侧 `/artifacts` 可列出常用生成产物读取命令,聊天侧 `/run-artifacts` 可列出最近 run 产物读取命令,聊天侧 `/run-files` 可列出 Agent 运行辅助文件读取命令,聊天侧 `/logs` 可列出固定日志读取命令且不直接读取日志,聊天侧 `/brief` 只基于当前已加载的 manifest / 最近 run trace / 预览状态 / 资产数量 / 最近命令生成项目简报,聊天侧 `/goal` 只基于当前 manifest.goal / 最近 run goal / taskGraph.goal 汇总创作目标来源,提供 `/next` 或 `/agent-resume 细化目标:` 后续草稿且不直接触发 Tauri 读写、文件读取、预览启动或新增面板,聊天侧 `/mvp` 只基于当前 manifest / 最近 run trace / preview / 任务 / 资产 / 最近命令汇总本轮最小可玩范围,提供 `/run` 等后续草稿且不直接触发 Tauri 读写、文件读取、预览启动、导出或新增面板,聊天侧 `/audience` 只基于当前 manifest / 最近 run trace / preview 准备首批试玩对象和观察重点且不直接触发 Tauri 读写、预览启动、导出或继续 run,聊天侧 `/feedback` 只基于当前 manifest / 最近 run trace / preview 准备试玩反馈模板和修改说明草稿且不直接触发 Tauri 读写、预览启动或继续 run,聊天侧 `/next` 基于当前已加载的 manifest / 最近 run trace 输出下一步建议和 `/goal` / `/mvp` / `/accessibility` / `/performance` / `/tasks` / `/criteria` / `/groups` / `/budget` / `/qa` / `/changes` / `/trace` / `/run` / `/open-preview` / `/test-plan` / `/audience` / `/feedback` / `/assets` / `/art` / `/context` / `/timeline` / `/artifacts` / `/run-artifacts` / `/run-files` / `/logs` / `/agent-resume ` 等安全命令草稿方向,提供一个首选草稿且不直接触发 Tauri 读写、预览启动或文件读取,聊天侧 `/capabilities` 展示标准 Agent 能力清单且不打开开发面板、聊天侧 `/audit` 从 manifest / 本地文件 / `.agent/run.latest.json` 分别汇总用户面、6 组任务配置、6 组协作证据、任务编排、loop、记忆、本地产物、HTTP 预览、画板回流和权限日志证据且不打开开发面板;未生成 `.agent/run.latest.json` 前,`/audit` 只能标记任务配置通过,不能把 6 组协作证据误判为通过;trace 已存在但状态为 `failed`、`needs-revision`、`running`、`max-passes-exhausted` 或缺少 `Evaluator passed` 步骤时,`/audit` 不能把 loop 误判为通过。聊天侧 `/llm-status` 只显示 base_url / model / API Key 已读取状态且不泄露密钥本体、聊天侧长期记忆查看 / 追加 / 覆盖 / 删除的授权本地项目路径、上传资产写入后的 manifest 刷新和 `/assets` 聊天可见性、`/smoke` 聊天侧确认后只通过授权本地项目路径执行白名单 `game.static_smoke`、`/run` 聊天侧确认后通过授权本地项目路径执行 `game.static_smoke`、启动 `127.0.0.1` 本地预览并切换客户端运行视图、`/preview` 聊天侧确认后通过授权本地项目路径启动 `127.0.0.1` 本地预览并切换客户端运行视图、`/status` 聊天侧项目 / 任务 / 资产 / 预览 / 最近命令汇总、`/files` 聊天侧本地项目文件列表、`/read` 聊天侧文件读取的授权本地项目路径、`/tasks` 聊天侧任务拆分与下一步专业组展示的授权本地项目路径、聊天确认生成后实时展示 Planner / Orchestrator / 角色 brief / Generator / Evaluator / 写盘 / 自检进度,并自动读取 `.agent/run.latest.json` 在普通聊天消息里展示 Run、LLM 对话、loop 轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照、`/trace` 聊天侧读取 `.agent/run.latest.json` 并展示 loop 轮次 / active 任务 / 返工路线 / dependency waves 的授权本地项目路径、`platform-agent` 编排测试、共享契约测试、Tauri 本地能力测试和无密钥本地 provider 端到端 smoke;用于证明独立 App、真实 LLM-compatible loop、本地落盘、自检和 HTTP 预览闭环,并覆盖 loop 跑满 3 轮失败时不会写入最终游戏产物。 - V1.17 单 Agent 持久计划验收:Rust 定向用例覆盖 native function 显式 `planUpdate`、文本 JSON omission 兼容、输入上限、单调 revision、外层 failed / budget-exhausted 保留最后可信进度、终态保留、工具 action 下标零推进、未完成步骤阻止 final、损坏状态失败关闭、context bundle v3/v2 恢复、finalization v2 计划快照与 assistant 已落盘后的 state 丢失恢复,以及 thinking / legacy plan / repair 公共审计零正文;`appSurface.test.ts` 覆盖开发 UI 刷新后完整 8 步仍在,以及普通用户 Supervisor 只显示完成数、当前步骤、等待、下一步和协作数量。2026-07-16 正式 `openai_chat / gpt-5.5` 的 `steer-runner-kill` suite 已证明 Runner boot 切换和 same-run steer 后 Agent/Session/run 不变、终态步骤不丢、revision 不回退、旧动作零执行、副作用零重放、唯一 assistant/completed 与零正文/密钥/路径公共泄漏,当前门禁状态为 PASS。 - V1.18 单 Agent 持久 Goal mode 验收:Rust/Runner 定向用例覆盖 Goal CAS 生命周期、当前 Session/run 隔离、Provider 中断安全边界、paused 重启不自启、同 run resume、旧 cancel tombstone 清理、当前 v5 context、v5 pending action 的 Goal 快照门禁、旧 schema 失败关闭、Goal edit 后自动/确认动作转 `blocked` 并重规划、finalization v3 以及 assistant 后 Runtime/Goal completed 顺序;`appSurface.test.ts` 覆盖 `执行 / 聊天 / 目标`、独立 Goal 弹层、完整控制动作和正式用户界面隔离。2026-07-16 真实 `openai_chat / gpt-5.5` 已完成 revision 1 -> 2、旧动作 blocked 且零执行、真实失败后 patchset 修复、pause、Runner pidfd 强杀换 boot、重启零推进、显式同 run resume;最终代码快照复跑有 11 组 Provider lifecycle 闭合、计划 revision 11 八步完成、唯一 assistant、零副作用重放和零 Goal/密钥/路径公共泄漏,门禁状态为 PASS。 @@ -862,3 +892,11 @@ game-project/ - 浏览器未发现、临时环境不可建、启动超时或在 WebSocket URL 解析前退出统一分类为 `preview-infrastructure-unavailable`。首个持久 observation 后收束当前 action batch并失败结束 child/root run,禁止继续用 Provider 逐轮规划同一 revision 的重复启动;普通页面/玩法验收失败仍保留为业务失败,不混入基础设施分类。 - game-chat release 在 `CloseRequested / ExitRequested` 前复用 Runner durable idle probe;只要存在 process session、pending/finalization/provider/tool-plan handoff 或非终态 Agent queue/phase,就阻止关闭并提示先完成、暂停或取消。不可撤销的最终 `Exit` 不再作为唯一保护点,Windows Job Object 的 child-owned 安全边界保持不变。 - 规范 Agent 默认推理档覆盖全部 21 个角色:核心规划、生成、设计/美术/代码原型和质量角色使用 `high`,协调与结构化交付使用 `medium`,确定性预览 gate、音频总监和发布策略使用 `low`;显式 `agentLlm..reasoningEffort` 始终最高优先。规范默认由 Runtime resolver 解析,模板与 GUI 初始草稿保持 `agentLlm` 为空,避免默认值被误判成角色独立 LLM 路由;GUI 必须显示每个角色的实际默认档。全局与逐 Agent status/CLI 必须同时显示实际解析后的 reasoning、request timeout、max retries 和 retry backoff,区分运行快照与后来配置。 + +## 2026-08-04 manifest 与工作台一致性收口 + +- `.agent/manifest.json` 的存储写边界使用同目录持久文件锁跨线程、跨进程串行化;锁必须覆盖旧 manifest 读取、不可变版本前缀校验、临时文件安装和安装后回读一致性校验。锁文件拒绝符号链接、非普通文件和异常所有权 / 硬链接;Windows 使用不共享写句柄,Unix 使用 `O_NOFOLLOW + flock`。旧快照在新版本安装后只能被拒绝,不能覆盖已追加版本。 +- 后台 Agent 的 manifest 变化以共用 Runtime 状态投影 / 终态 emitter 作为失效因果点:`game-creator-agent-runtime-update` 的 Rust / TypeScript DTO 固定携带 `manifestInvalidated`,且 App 必须在 Supervisor、selected agent、session 和 run 身份的任何 early return 之前处理失效。GUI 进程内 Runtime 直接发该事件;External Runner 是独立进程、没有 GUI `AppHandle`,因此 Runner 协议 v5 的 `runner.attach_gui_owner` 必须登记 GUI 创建的随机 loopback 端口和 64 位随机令牌,Runner 的同一 emitter 通过受令牌保护的短连接转发 `game-creator-manifest-invalidated`。两条路径都只传项目路径与 Agent 身份,不复制 manifest,也不靠轮询补偿。 +- App 收到当前项目的 Runtime / relay 失效后重新调用 `get_local_game_manifest`。重读按项目 single-flight 合并事件风暴;读取中再到达失效只追加一轮串行重读,不并发提交同项目响应。应用结果同时校验组件仍挂载、当前项目路径和项目 scope version;项目切换、组件卸载或旧 scope 的迟到响应不得覆盖新项目。Project Supervisor 再通过既有 `onManifestChange(projectPath, manifest)` 向启动器外传完整快照,启动器只更新仍为同一路径的活动项目上下文;资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目。 +- `.agent/agent.db` 有界尾部读取报告截断时,审计 producer 映射失败关闭,不生成基于不完整审计的 producer、task flow 或对应任务环。前端收到截断 DTO 时只剔除 `producerAssignments`、`taskFlows` 与对应 `cyclicTaskIds`;Rust 根据当前 manifest、精确资源引用和仍可信任务深度下限返回的 `dependencyDepths` 继续保留,前端只校验资源仍存在且深度为非负安全整数,不得自行重算或压平权威深度。精确引用边、reference connection index、`cyclicResourceIds` 与 unresolved references 同样继续保留。 +- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦按稳定 `resourceId` 驱动焦点状态:仅 `null -> id` 或 `idA -> idB` 聚焦详情 region,同一 ID 的 manifest 重投影不得抢走音频、视频、链接或关闭按钮焦点;显式收起和 Escape 恢复画布滚动并优先聚焦原触发卡片。聚焦资源被删除时清理 stale focused / selected ID,关闭详情并把焦点落到资源搜索框;项目切换或运行视图切换清除旧恢复意图,不得恢复旧项目卡片。橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index f15ef8f9f..8fc01536e 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -524,7 +524,7 @@ GameBridge 禁止: 2026-06-19 追加:桌面壳 macOS 媒体权限说明进入门禁。Tauri 桌面壳仍不新增摄像头或麦克风 HostBridge method,不把系统媒体能力暴露成桌面命令;同源 H5 页面可继续使用浏览器标准 `getUserMedia` 承接儿童动作热身 Demo 的实时摄像头输入和汪汪声浪正式 runtime 的实时麦克风输入。macOS 分发包必须通过 `bundle.macOS.infoPlist="Info.plist"` 合并受控用途说明:`NSCameraUsageDescription` 只描述同源 H5 实时动作输入,`NSMicrophoneUsageDescription` 只描述同源 H5 实时声音玩法。`apps/desktop-shell/scripts/check-config.mjs` 会校验 plist 路径和两条文案,并把 `Info.plist` 纳入生产壳替身词扫描,防止桌面包缺少系统授权说明、把媒体权限扩写成通用采集能力,或在 macOS 分发配置里留下临时替身文本。 -2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认 Tauri release 入口指向共享公开主站、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;构建后 `desktop-shell:stage-release-binary` 会把当前平台二进制复制到根目录 `build/native/desktop/genarrative-desktop-shell` 或 `build/native/desktop/genarrative-desktop-shell.exe`,该目录沿用根 `build/` 的 gitignore,只作为本机或 CI 可收集产物目录。统一验收必须检查 staged 二进制存在、非空且符合当前平台可执行文件头。`apps/desktop-shell/scripts/check-config.mjs` 会反查根级门禁仍保留 release build smoke、staging 步骤、二进制路径、Linux ELF / macOS Mach-O / Windows PE 文件头和可执行位检查,避免桌面产物验收被改成只看命令退出码。该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。 +2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认 Tauri release 入口指向共享公开主站、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;构建后 `desktop-shell:stage-release-binary` 会把当前平台二进制复制到根目录 `build/native/desktop/genarrative-desktop-shell` 或 `build/native/desktop/genarrative-desktop-shell.exe`,该目录沿用根 `build/` 的 gitignore,只作为本机或 CI 可收集产物目录。统一验收必须检查 staged 二进制存在、非空且符合当前平台可执行文件头;macOS 校验同时接受 32/64 位与 fat Mach-O 的大端、反字节序合法魔数,不得把 arm64 常见的 `cf fa ed fe` 文件头误拒绝。`apps/desktop-shell/scripts/check-config.mjs` 会反查根级门禁仍保留 release build smoke、staging 步骤、二进制路径、Linux ELF / macOS Mach-O / Windows PE 文件头和可执行位检查,避免桌面产物验收被改成只看命令退出码。该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。 2026-06-18 追加:移动壳 Expo managed config 烟测进入统一验收。`npm run check:native-shells` 会执行 `npm run mobile-shell:config`,在 `apps/mobile-shell` 目录内调用 `expo config --type public --json`,校验 Expo CLI 实际解析结果中的包名、scheme、深链、ATS / cleartext / backup / 相机与麦克风权限、启动页、adaptive icon、插件配置和 HostBridge 版本没有漂移。`apps/mobile-shell/scripts/check-config.mjs` 会反查根级门禁仍保留 EAS build profile、Expo config 和 Metro export 三个移动分发烟测,避免移动壳验收退回到只看源码类型检查。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index cf62e1db0..506a01d97 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,6 +1,6 @@ # 本地开发验证与生产运维 -更新时间:`2026-08-03` +更新时间:`2026-08-05` ## 标准开发流程 @@ -230,7 +230,7 @@ npm run check - `Repository checks`:执行 `npm run lint`、主站与后台生产构建、内容数据检查和提交差异空白检查。 - `Frontend tests`:按根 lockfile 与 `apps/ai-game-creator-shell/package-lock.json` 分别执行干净的 `npm ci`,再独立执行根 `npm run test`、`npm run bgfilter-worker:smoke-test`、`npm run check:production-health-patrol`、`npm run check:production-api-release` 和 `npm run check:production-api-deploy`,让 Vitest、Node test smoke harness 及不依赖真实服务的生产巡检 / 发布 / 部署行为 fixture 在 Gitea job 中持续执行;其中 `.test.mjs` 使用 Node test runner,不依赖 Vitest 的 `scripts/**/*.test.ts` 收集规则。 -- `Backend tests`:执行 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --no-fail-fast`、`api-server --all-targets` 编译和 `spacetime-module` 编译;runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。依赖真实服务或密钥的测试必须显式 `ignored`,不能让普通 PR job访问现场环境。 +- `Backend tests`:先对 `server-rs/Cargo.lock` 执行带 5 次整命令级有界重试的 `cargo fetch --locked`,再执行 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --no-fail-fast`、`api-server --all-targets` 编译和 `spacetime-module` 编译;依赖准备必须位于会触发 Cargo build 的 DDD / 产物边界门禁之前,避免锁新增依赖未命中镜像缓存时绕过既有下载重试。runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。依赖真实服务或密钥的测试必须显式 `ignored`,不能让普通 PR job访问现场环境。 - `Native shell tests`:按根 lockfile 与 AI 游戏创作壳独立 lockfile 安装依赖后执行 `npm run check:native-shells`,覆盖微信壳、Expo 和 Tauri 的完整验收,并确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。`codex/ai-game-creator-app` 分支的同名脚本还会执行 `npm run ai-game-creator-shell:check` 和 AI 游戏创作壳 release build smoke;共享 Agent Runtime 后台锁 suite 固定 `--test-threads=1`,不能用并行偶发失败后的逐项通过替代整套稳定门禁。 四个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。 @@ -607,7 +607,7 @@ Nginx 与 Pingora 在维护 marker 存在时对内网来源绕过整站维护闸 该规则只绕过网关维护响应,不会自动拉起 api-server、SpacetimeDB 或其它已停止的服务。人工执行 `maintenance-on.sh` 且后端仍运行时,可以从内网继续访问整站和修改后台数据;`pause-after-stdb` 会停止旧 API/controller/worker,在 API 被停期间静态页面可能仍可加载,但普通 API 与 `/admin/api/**` 仍不可用。验证使用 `npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml`、`npm run check:pingora-gateway-smoke` 和 `npm run check:production-ops`,不要在 live 机器上为测试临时创建维护 marker。 -版本化默认维护页固定为 `public/maintenance.html`,使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉,只允许保存无日期、无具体时段的通用文案;正常 Web 构建由 Vite 复制到发布包根目录的 `web/maintenance.html`,并由 `check-maintenance-page.mjs` 在打包前拒绝“今天 / 今晚”、具体日期或 `HH:MM` 等临时公告。计划内停服的临时公告必须放在 release 外文件中,通过 `/opt/genarrative/current/scripts/deploy/maintenance-on.sh --page-file <公告HTML> <维护原因>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`。Nginx 与 Pingora 在该文件存在时优先返回它,缺失时回退当前 Web 制品的默认维护页;同一维护窗口内 Stdb / API 的后续 `maintenance-on.sh` 调用保留已安装公告,`maintenance-off.sh` 同时删除 marker 和运行态公告,避免下次维护复活旧内容。公告启用后同时用 `genarrative.world` 与 `www.genarrative.world` 的真实 HTTPS 响应校验 `503` 和公告正文。 +版本化默认维护页固定为 `public/maintenance.html`,使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉,只允许保存无日期、无具体时段的通用文案;正常 Web 构建由 Vite 复制到发布包根目录的 `web/maintenance.html`,并由 `check-maintenance-page.mjs` 在打包前拒绝“今天 / 今晚”、具体日期或 `HH:MM` 等临时公告。计划内停服的临时公告必须放在 release 外文件中,通过 `/opt/genarrative/current/scripts/deploy/maintenance-on.sh --page-file <公告HTML> <维护原因>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`;公告页和 marker 都使用同目录临时文件加 POSIX 兼容 `mv -f` 的原子替换,不得依赖 GNU `mv -T`,确保 Linux 生产机与 macOS/BSD 本地门禁语义一致。Nginx 与 Pingora 在该文件存在时优先返回它,缺失时回退当前 Web 制品的默认维护页;同一维护窗口内 Stdb / API 的后续 `maintenance-on.sh` 调用保留已安装公告,`maintenance-off.sh` 同时删除 marker 和运行态公告,避免下次维护复活旧内容。公告启用后同时用 `genarrative.world` 与 `www.genarrative.world` 的真实 HTTPS 响应校验 `503` 和公告正文。 生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。所有生产 Job 的 SCM URL,以及 Jenkinsfile 内部在 Jenkins Built-In Node 执行的源码准备,统一使用 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`,并显式传入 Jenkins SSH 凭据 `genarrative-local-gitea-ssh`;不再配置局域网 IP、`https://git.genarrative.world/...` 公网 fallback 或 `http://genarrative-station/git/GenarrativeAI/Genarrative.git`。所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。API / Web / Stdb 发布类流水线不在目标机器 checkout Git,统一执行上游构建归档里的部署脚本;Server-Provision 和数据库导入导出也由带 `linux && genarrative-build` 标签的 Jenkins Built-In Node 先 checkout 并 stash 所需脚本,再交给目标 dev / release agent,避免目标机把 `127.0.0.1` 误解为远端 Gitea 或让产物 commit 与执行脚本漂移。 @@ -898,3 +898,7 @@ node scripts/rebind-orphan-work-owners.mjs --in --out - `--out`:写回后的迁移 JSON 输出路径。 - `--dry-run`:只统计回填行数,不写文件。 - `--placeholder-user-id`:需要时可覆盖默认占位账号 ID。 + +## 维护页目标文件安全边界(2026-08-05) + +`scripts/deploy/maintenance-on.sh` 只允许把同目录临时普通文件原子替换到普通文件或尚不存在的 `page.html` / `enabled` 目标。目标只要是符号链接(包括指向目录的链接)或目录,脚本必须在替换前失败,不能跟随链接把临时文件移入链接目标,也不能打印“已进入维护模式”。`page_temp` 与 `marker_temp` 必须在 `set -u` 下安全初始化,清理 trap 必须在首次 `mktemp` 前生效;任一失败退出都不得在目标同级遗留 `page.html.tmp.*` 或 `enabled.tmp.*`,成功替换后则清空临时路径并解除 trap,不能误删已安装目标。跨平台实现继续使用 POSIX `mv -f`,安全语义由替换函数的目标类型门禁保证;修改后运行 `bash -n scripts/deploy/maintenance-on.sh` 与 `npm run check:maintenance-page`。 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 655329af0..0e0b97cf5 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -638,6 +638,16 @@ describe('AI 游戏创作 App 共享契约', () => { updatedAt: 123, }, ], + versions: [ + { + versionId: 'version-1', + parentVersionId: null, + projectRevision: 7, + resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }], + createdReason: 'initial', + createdAt: 456, + }, + ], assets: [ { id: 'asset-player', @@ -678,6 +688,16 @@ describe('AI 游戏创作 App 共享契约', () => { logPath: '.agent/logs/command.log', }, ], + versions: [ + { + versionId: 'version-1', + parentVersionId: null, + projectRevision: 7, + resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }], + createdReason: 'initial', + createdAt: 456, + }, + ], }); }); }); diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index adcba62ba..40b891a51 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -537,6 +537,25 @@ export interface GameCreationAppCommandRunState { updatedAt: number; } +export type GameIterationVersionCreatedReason = + | 'initial' + | 'resource-replacement' + | 'agent-revision'; + +export interface GameIterationVersionResourceBinding { + slotId: string; + resourceId: string; +} + +export interface GameIterationVersion { + versionId: string; + parentVersionId: string | null; + projectRevision: number; + resourceBindings: GameIterationVersionResourceBinding[]; + createdReason: GameIterationVersionCreatedReason; + createdAt: number; +} + export interface GameCreationAppManifest { schemaVersion: string; projectId: string; @@ -546,6 +565,7 @@ export interface GameCreationAppManifest { assets: GameCreationAppAssetManifestEntry[]; preview?: GameCreationAppPreviewState | null; commandRuns?: GameCreationAppCommandRunState[]; + versions?: GameIterationVersion[]; } export interface GameCreationAgentToolCallTrace { diff --git a/scripts/check-maintenance-page.mjs b/scripts/check-maintenance-page.mjs index d82e1f2e4..f0ba8e54f 100644 --- a/scripts/check-maintenance-page.mjs +++ b/scripts/check-maintenance-page.mjs @@ -4,11 +4,15 @@ import { spawnSync } from 'node:child_process'; import { chmodSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, statSync, + symlinkSync, + unlinkSync, writeFileSync, } from 'node:fs'; import os from 'node:os'; @@ -68,11 +72,19 @@ function validateRuntimePageLifecycle() { const sourcePageFile = path.join(tempRoot, 'announcement.html'); const onScript = path.join(repoRoot, 'scripts/deploy/maintenance-on.sh'); const offScript = path.join(repoRoot, 'scripts/deploy/maintenance-off.sh'); + const onScriptSource = readFileSync(onScript, 'utf8'); const env = { GENARRATIVE_MAINTENANCE_FILE: markerFile, GENARRATIVE_MAINTENANCE_PAGE_FILE: runtimePageFile, }; + if (!onScriptSource.includes('replace_file_atomically')) { + fail('maintenance-on 必须通过统一 helper 原子替换公告页和 marker。'); + } + if (/\bmv\s+-[^\s]*T\b/u.test(onScriptSource)) { + fail('maintenance-on 不得使用 GNU mv 专属的 -T 参数。'); + } + try { const announcement = 'planned maintenance\n'; writeFileSync(sourcePageFile, announcement); @@ -133,6 +145,74 @@ function validateRuntimePageLifecycle() { if (missingPage.status === 0 || existsSync(markerFile)) { fail('不存在的 --page-file 必须在创建 marker 前失败。'); } + + const linkedPageTarget = path.join(tempRoot, 'linked-page-target'); + mkdirSync(linkedPageTarget); + symlinkSync( + linkedPageTarget, + runtimePageFile, + process.platform === 'win32' ? 'junction' : 'dir', + ); + const linkedPageEnable = runScript( + onScript, + ['--page-file', sourcePageFile, 'linked page target'], + env, + ); + if (linkedPageEnable.status === 0) { + fail('maintenance-on 必须拒绝指向目录的公告页符号链接。'); + } + if (!lstatSync(runtimePageFile).isSymbolicLink()) { + fail('拒绝公告页符号链接后不得替换链接本身。'); + } + if (readdirSync(linkedPageTarget).length > 0) { + fail('拒绝公告页符号链接后不得把临时文件移入链接目标目录。'); + } + if ( + readdirSync(path.dirname(runtimePageFile)).some((entry) => + entry.startsWith(`${path.basename(runtimePageFile)}.tmp.`), + ) + ) { + fail('公告页符号链接校验失败后不得残留 page.html.tmp.* 临时文件。'); + } + if (existsSync(markerFile)) { + fail('公告页符号链接校验失败时不得创建维护 marker。'); + } + unlinkSync(runtimePageFile); + + const linkedMarkerTarget = path.join(tempRoot, 'linked-marker-target'); + mkdirSync(linkedMarkerTarget); + symlinkSync( + linkedMarkerTarget, + markerFile, + process.platform === 'win32' ? 'junction' : 'dir', + ); + const linkedMarkerEnable = runScript( + onScript, + ['linked marker target'], + env, + ); + if (linkedMarkerEnable.status === 0) { + fail('maintenance-on 必须拒绝指向目录的 marker 符号链接。'); + } + if (!lstatSync(markerFile).isSymbolicLink()) { + fail('拒绝 marker 符号链接后不得替换链接本身。'); + } + if (readdirSync(linkedMarkerTarget).length > 0) { + fail('拒绝 marker 符号链接后不得把临时文件移入链接目标目录。'); + } + if ( + readdirSync(path.dirname(markerFile)).some((entry) => + entry.startsWith(`${path.basename(markerFile)}.tmp.`), + ) + ) { + fail('marker 符号链接校验失败后不得残留 enabled.tmp.* 临时文件。'); + } + if ( + linkedMarkerEnable.stdout.includes('已进入维护模式') || + linkedMarkerEnable.stderr.includes('已进入维护模式') + ) { + fail('marker 符号链接校验失败时不得打印维护模式成功信息。'); + } } finally { rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/scripts/check-module-runtime-artifact.mjs b/scripts/check-module-runtime-artifact.mjs index 00992cbab..ad3ea0dc4 100644 --- a/scripts/check-module-runtime-artifact.mjs +++ b/scripts/check-module-runtime-artifact.mjs @@ -165,7 +165,8 @@ function parseArchiveObjectMembers(artifact) { } memberName = artifact .subarray(contentStart, contentStart + nameLength) - .toString('utf8'); + .toString('utf8') + .replace(/\0+$/u, ''); contentStart += nameLength; } diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index bbfd91361..c72290867 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -4915,9 +4915,13 @@ function assertDesktopReleaseBinaryArtifact() { const machMagic = header.readUInt32BE(0); const isMachO = machMagic === 0xcafebabe || - machMagic === 0xcafed00d || + machMagic === 0xbebafeca || + machMagic === 0xcafebabf || + machMagic === 0xbfbafeca || machMagic === 0xfeedface || - machMagic === 0xfeedfacf; + machMagic === 0xcefaedfe || + machMagic === 0xfeedfacf || + machMagic === 0xcffaedfe; if (!isMachO || (stat.mode & 0o111) === 0) { throw new Error( 'desktop macOS release binary must be an executable Mach-O file', diff --git a/scripts/check-production-api-deploy.mjs b/scripts/check-production-api-deploy.mjs index efeb4aa84..4532c6351 100644 --- a/scripts/check-production-api-deploy.mjs +++ b/scripts/check-production-api-deploy.mjs @@ -15,6 +15,22 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; const failures = []; +const systemCpPath = ['/usr/bin/cp', '/bin/cp'].find((candidate) => + existsSync(candidate), +); +const systemMvPath = ['/usr/bin/mv', '/bin/mv'].find((candidate) => + existsSync(candidate), +); +const systemLnPath = ['/usr/bin/ln', '/bin/ln'].find((candidate) => + existsSync(candidate), +); +const systemChmodPath = ['/usr/bin/chmod', '/bin/chmod'].find((candidate) => + existsSync(candidate), +); +const systemStatModeCommand = + process.platform === 'darwin' + ? '/usr/bin/stat -f %Lp' + : '/usr/bin/stat -c %a --'; const tmpRoot = mkdtempSync( path.join(tmpdir(), 'genarrative-production-api-deploy-'), ); @@ -2421,7 +2437,7 @@ function prepareFixture(name) { [ '#!/usr/bin/env bash', 'set -euo pipefail', - '/usr/bin/cp "$@"', + `${shellQuote(systemCpPath ?? 'cp')} "$@"`, 'if [[ "${FAKE_CREATE_RELEASE_DURING_COPY:-false}" == "true" ]]; then', ' marker="${FAKE_RELEASE_ROOT}/.${FAKE_RELEASE_VERSION}.race-created"', ' if [[ ! -e "${marker}" ]]; then', @@ -2434,6 +2450,40 @@ function prepareFixture(name) { ].join('\n'), 'utf8', ); + writeFileSync( + path.join(fakeBin, 'mv'), + [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + 'if [[ "${1:-}" == "-T" ]]; then', + ' shift', + ' if [[ "$#" -ne 2 || ( -d "$2" && ! -L "$2" ) ]]; then', + ' exit 1', + ' fi', + ` exec ${shellQuote(systemMvPath ?? 'mv')} "$1" "$2"`, + 'fi', + `exec ${shellQuote(systemMvPath ?? 'mv')} "$@"`, + '', + ].join('\n'), + 'utf8', + ); + writeFileSync( + path.join(fakeBin, 'ln'), + [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + 'if [[ "${1:-}" == "-sfnT" ]]; then', + ' shift', + ' if [[ "$#" -ne 2 || ( -d "$2" && ! -L "$2" ) ]]; then', + ' exit 1', + ' fi', + ` exec ${shellQuote(systemLnPath ?? 'ln')} -sfn "$1" "$2"`, + 'fi', + `exec ${shellQuote(systemLnPath ?? 'ln')} "$@"`, + '', + ].join('\n'), + 'utf8', + ); writeFileSync( path.join(fakeBin, 'install'), [ @@ -2479,15 +2529,15 @@ function prepareFixture(name) { ' IFS="|" read -r -a env_files <<< "${FAKE_SUDO_ENV_FILES}"', ' modes=()', ' for env_file in "${env_files[@]}"; do', - ' modes+=("$(/usr/bin/stat -c %a -- "${env_file}")")', - ' /usr/bin/chmod u+rw -- "${env_file}"', + ` modes+=("$(${systemStatModeCommand} "\${env_file}")")`, + ` ${shellQuote(systemChmodPath ?? 'chmod')} u+rw "\${env_file}"`, ' done', ' set +e', ' "$@"', ' status=$?', ' set -e', ' for index in "${!env_files[@]}"; do', - ' /usr/bin/chmod "${modes[$index]}" -- "${env_files[$index]}"', + ` ${shellQuote(systemChmodPath ?? 'chmod')} "\${modes[$index]}" "\${env_files[$index]}"`, ' done', ' exit "${status}"', 'fi', @@ -2501,6 +2551,8 @@ function prepareFixture(name) { chmodExecutable(path.join(fakeBin, 'sleep')); chmodExecutable(path.join(fakeBin, 'stat')); chmodExecutable(path.join(fakeBin, 'cp')); + chmodExecutable(path.join(fakeBin, 'mv')); + chmodExecutable(path.join(fakeBin, 'ln')); chmodExecutable(path.join(fakeBin, 'install')); chmodExecutable(path.join(fakeBin, 'sudo')); diff --git a/scripts/deploy/maintenance-on.sh b/scripts/deploy/maintenance-on.sh index 66df9ddb4..c1102c2aa 100644 --- a/scripts/deploy/maintenance-on.sh +++ b/scripts/deploy/maintenance-on.sh @@ -6,6 +6,36 @@ MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenan MAINTENANCE_PAGE_FILE="${GENARRATIVE_MAINTENANCE_PAGE_FILE:-/var/lib/genarrative/maintenance/page.html}" PAGE_SOURCE="" REASON_PARTS=() +page_temp="" +marker_temp="" + +cleanup_temps() { + if [[ -n "${page_temp}" ]]; then + rm -f -- "${page_temp}" + fi + if [[ -n "${marker_temp}" ]]; then + rm -f -- "${marker_temp}" + fi +} + +trap cleanup_temps EXIT + +replace_file_atomically() { + local source_file="$1" + local target_file="$2" + + if [[ -L "${target_file}" ]]; then + echo "[maintenance] 原子替换目标不能是符号链接: ${target_file}" >&2 + exit 1 + fi + if [[ -d "${target_file}" ]]; then + echo "[maintenance] 原子替换目标不能是目录: ${target_file}" >&2 + exit 1 + fi + # 源文件与目标文件位于同一目录,POSIX rename 语义即可保证原子替换。 + # 不使用 GNU mv 专属的 -T,确保 macOS/BSD 本地门禁也能执行。 + mv -f "${source_file}" "${target_file}" +} while [[ $# -gt 0 ]]; do case "$1" in @@ -39,9 +69,8 @@ mkdir -p "$(dirname "${MAINTENANCE_FILE}")" "$(dirname "${MAINTENANCE_PAGE_FILE} if [[ -n "${PAGE_SOURCE}" ]]; then page_temp="$(mktemp "${MAINTENANCE_PAGE_FILE}.tmp.XXXXXX")" - trap 'rm -f "${page_temp:-}" "${marker_temp:-}"' EXIT install -m 0644 -- "${PAGE_SOURCE}" "${page_temp}" - mv -fT -- "${page_temp}" "${MAINTENANCE_PAGE_FILE}" + replace_file_atomically "${page_temp}" "${MAINTENANCE_PAGE_FILE}" page_temp="" echo "[maintenance] 已安装本次运行态公告页: ${MAINTENANCE_PAGE_FILE}" elif [[ ! -f "${MAINTENANCE_FILE}" && ( -e "${MAINTENANCE_PAGE_FILE}" || -L "${MAINTENANCE_PAGE_FILE}" ) ]]; then @@ -55,7 +84,7 @@ marker_temp="$(mktemp "${MAINTENANCE_FILE}.tmp.XXXXXX")" } >"${marker_temp}" chmod 0644 "${marker_temp}" -mv -fT -- "${marker_temp}" "${MAINTENANCE_FILE}" +replace_file_atomically "${marker_temp}" "${MAINTENANCE_FILE}" marker_temp="" trap - EXIT echo "[maintenance] 已进入维护模式: ${MAINTENANCE_FILE}" diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index b3713bbab..5919fa220 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -892,7 +892,9 @@ ensure_default_worker_service() { return 1 fi - mapfile -t services < <(list_worker_services "${pattern}") + while IFS= read -r service; do + services+=("${service}") + done < <(list_worker_services "${pattern}") if [[ "${#services[@]}" -gt 0 ]]; then return 0 fi @@ -1033,7 +1035,9 @@ restart_worker_services() { fi ensure_default_worker_service "${pattern}" - mapfile -t services < <(list_worker_services "${pattern}") + while IFS= read -r service; do + services+=("${service}") + done < <(list_worker_services "${pattern}") if [[ "${#services[@]}" -eq 0 ]]; then echo "[production-api-deploy] 未发现已加载的外部生成 worker 单元: ${pattern}" >&2 return 1 @@ -1052,7 +1056,9 @@ wait_for_worker_services() { return 0 fi - mapfile -t services < <(list_worker_services "${pattern}") + while IFS= read -r service; do + services+=("${service}") + done < <(list_worker_services "${pattern}") if [[ "${#services[@]}" -eq 0 ]]; then echo "[production-api-deploy] 外部生成 worker 单元不存在,发布失败: ${pattern}" >&2 return 1 diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts new file mode 100644 index 000000000..cd0e94ee9 --- /dev/null +++ b/scripts/project-ci-workflow.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const workflow = readFileSync( + resolve(process.cwd(), '.gitea/workflows/project-ci.yml'), + 'utf8', +); + +function backendStepIndex(stepName: string) { + const backendJobStart = workflow.indexOf(' backend-tests:'); + const nativeShellJobStart = workflow.indexOf(' native-shell-tests:'); + expect(backendJobStart).toBeGreaterThanOrEqual(0); + expect(nativeShellJobStart).toBeGreaterThan(backendJobStart); + + return workflow + .slice(backendJobStart, nativeShellJobStart) + .indexOf(` - name: ${stepName}`); +} + +describe('project CI workflow', () => { + it('prepares locked server-rs dependencies before the first Cargo build gate', () => { + const prepareDependencies = backendStepIndex( + 'Prepare server-rs Rust dependencies', + ); + const checkBoundaries = backendStepIndex('Check server-rs boundaries'); + const runWorkspaceTests = backendStepIndex('Run server-rs workspace tests'); + + expect(prepareDependencies).toBeGreaterThanOrEqual(0); + expect(checkBoundaries).toBeGreaterThan(prepareDependencies); + expect(runWorkspaceTests).toBeGreaterThan(checkBoundaries); + expect(workflow).toContain('cargo fetch --locked'); + expect(workflow).toContain('for attempt in $(seq 1 5); do'); + }); +}); diff --git a/scripts/spacetime-repair-editor-canvas-resources.mjs b/scripts/spacetime-repair-editor-canvas-resources.mjs index a8967d9c7..35562225c 100644 --- a/scripts/spacetime-repair-editor-canvas-resources.mjs +++ b/scripts/spacetime-repair-editor-canvas-resources.mjs @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { lstat, readFile, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -85,15 +86,20 @@ export async function readRepairPlan(planFile, { repoRoot = REPO_ROOT } = {}) { const resolvedPath = path.resolve(planFile); const resolvedRepoRoot = path.resolve(repoRoot); let canonicalPath; + let canonicalRepoRoot; try { canonicalPath = await realpath(resolvedPath); + canonicalRepoRoot = await realpath(resolvedRepoRoot); } catch { throw new Error('--plan-file 无法解析或不存在。'); } - if (canonicalPath !== resolvedPath) { + const pathWithoutSystemTempAlias = await normalizeSystemTempAlias( + resolvedPath, + ); + if (canonicalPath !== pathWithoutSystemTempAlias) { throw new Error('--plan-file 路径链不能包含符号链接。'); } - if (isPathInside(canonicalPath, resolvedRepoRoot)) { + if (isPathInside(canonicalPath, canonicalRepoRoot)) { throw new Error('--plan-file 必须位于仓库外,避免真实 ID 进入工作区。'); } @@ -539,6 +545,16 @@ function isPathInside(candidate, root) { ); } +async function normalizeSystemTempAlias(candidate) { + const resolvedTempRoot = path.resolve(tmpdir()); + if (!isPathInside(candidate, resolvedTempRoot)) { + return candidate; + } + + const canonicalTempRoot = await realpath(resolvedTempRoot); + return path.join(canonicalTempRoot, path.relative(resolvedTempRoot, candidate)); +} + function assertPlainObject(value, label) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`${label} 必须是对象。`); diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 0d2225abe..5da4426df 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; pub const GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION: &str = "game-creation-app.manifest.v1"; pub const GAME_CREATION_AGENT_RUN_SCHEMA_VERSION: &str = "game-creator-agent-run.v1"; @@ -611,6 +611,142 @@ pub struct GameCreationAppCommandRunState { pub updated_at: u64, } +pub const GAME_ITERATION_VERSION_MAX_COUNT: usize = 4096; +pub const GAME_ITERATION_VERSION_MAX_BINDING_COUNT: usize = 4096; +pub const GAME_ITERATION_VERSION_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum GameIterationVersionCreatedReason { + Initial, + ResourceReplacement, + AgentRevision, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameIterationVersionResourceBinding { + pub slot_id: String, + pub resource_id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameIterationVersion { + pub version_id: String, + pub parent_version_id: Option, + pub project_revision: u64, + pub resource_bindings: Vec, + pub created_reason: GameIterationVersionCreatedReason, + pub created_at: u64, +} + +fn validate_iteration_version_id(value: &str, label: &str, max_chars: usize) -> Result<(), String> { + if value.is_empty() || value.trim() != value { + return Err(format!("{label}不能为空或包含首尾空白")); + } + if value.chars().count() > max_chars || value.chars().any(char::is_control) { + return Err(format!("{label}无效")); + } + Ok(()) +} + +pub fn validate_game_iteration_versions(versions: &[GameIterationVersion]) -> Result<(), String> { + if versions.len() > GAME_ITERATION_VERSION_MAX_COUNT { + return Err(format!( + "项目版本最多支持 {GAME_ITERATION_VERSION_MAX_COUNT} 条" + )); + } + + let mut previous_versions = HashMap::<&str, (u64, u64)>::new(); + for (index, version) in versions.iter().enumerate() { + validate_iteration_version_id(&version.version_id, "版本 ID", 128)?; + if previous_versions.contains_key(version.version_id.as_str()) { + return Err(format!("项目版本 ID 重复:{}", version.version_id)); + } + if version.project_revision > GAME_ITERATION_VERSION_MAX_SAFE_INTEGER { + return Err(format!( + "项目版本 {} 的 projectRevision 超出 JavaScript 安全整数范围", + version.version_id + )); + } + if version.created_at > GAME_ITERATION_VERSION_MAX_SAFE_INTEGER { + return Err(format!( + "项目版本 {} 的 createdAt 超出 JavaScript 安全整数范围", + version.version_id + )); + } + + if index == 0 { + if version.parent_version_id.is_some() + || version.created_reason != GameIterationVersionCreatedReason::Initial + { + return Err(format!( + "项目版本 {} 的首条记录必须是无父版本的 initial 版本", + version.version_id + )); + } + } else { + if version.created_reason == GameIterationVersionCreatedReason::Initial { + return Err(format!( + "项目版本 {} 只有首个版本可以使用 initial 创建原因", + version.version_id + )); + } + let Some(parent_version_id) = &version.parent_version_id else { + return Err(format!( + "项目版本 {} 只有首个 initial 版本可以没有父版本", + version.version_id + )); + }; + validate_iteration_version_id(parent_version_id, "父版本 ID", 128)?; + let Some((parent_revision, parent_created_at)) = + previous_versions.get(parent_version_id.as_str()) + else { + return Err(format!( + "项目版本 {} 的父版本必须先于子版本存在", + version.version_id + )); + }; + if version.project_revision <= *parent_revision { + return Err(format!( + "项目版本 {} 的 projectRevision 必须大于父版本", + version.version_id + )); + } + if version.created_at < *parent_created_at { + return Err(format!( + "项目版本 {} 的 createdAt 不能早于父版本", + version.version_id + )); + } + } + + if version.resource_bindings.len() > GAME_ITERATION_VERSION_MAX_BINDING_COUNT { + return Err(format!( + "项目版本 {} 的资源绑定最多支持 {GAME_ITERATION_VERSION_MAX_BINDING_COUNT} 项", + version.version_id + )); + } + let mut slot_ids = HashSet::new(); + for binding in &version.resource_bindings { + validate_iteration_version_id(&binding.slot_id, "版本资源槽位 ID", 256)?; + validate_iteration_version_id(&binding.resource_id, "版本资源 ID", 512)?; + if !slot_ids.insert(binding.slot_id.as_str()) { + return Err(format!( + "项目版本 {} 的资源槽位重复:{}", + version.version_id, binding.slot_id + )); + } + } + previous_versions.insert( + version.version_id.as_str(), + (version.project_revision, version.created_at), + ); + } + Ok(()) +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameCreationAppManifest { @@ -626,6 +762,8 @@ pub struct GameCreationAppManifest { pub preview: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub command_runs: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub versions: Vec, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -757,6 +895,7 @@ pub fn new_game_creation_app_manifest( assets: Vec::new(), preview: None, command_runs: Vec::new(), + versions: Vec::new(), } } @@ -1594,6 +1733,17 @@ mod tests { log_path: ".agent/logs/command.log".to_string(), updated_at: 123, }); + manifest.versions.push(GameIterationVersion { + version_id: "version-1".to_string(), + parent_version_id: None, + project_revision: 7, + resource_bindings: vec![GameIterationVersionResourceBinding { + slot_id: "player".to_string(), + resource_id: "asset-player".to_string(), + }], + created_reason: GameIterationVersionCreatedReason::Initial, + created_at: 456, + }); manifest.assets.push(GameCreationAppAssetManifestEntry { id: "asset-player".to_string(), kind: "character".to_string(), @@ -1630,6 +1780,95 @@ mod tests { payload["assets"][0]["source"]["canvasProjectId"], json!("canvas-project-1") ); + assert_eq!(payload["versions"][0]["versionId"], json!("version-1")); + assert_eq!( + payload["versions"][0]["resourceBindings"][0]["slotId"], + json!("player") + ); + assert_eq!(payload["versions"][0]["createdReason"], json!("initial")); + } + + #[test] + fn iteration_versions_require_an_append_ordered_parent_graph() { + let root = GameIterationVersion { + version_id: "version-root".to_string(), + parent_version_id: None, + project_revision: 4, + resource_bindings: vec![GameIterationVersionResourceBinding { + slot_id: "player".to_string(), + resource_id: "asset-player".to_string(), + }], + created_reason: GameIterationVersionCreatedReason::Initial, + created_at: 100, + }; + let child = GameIterationVersion { + version_id: "version-child".to_string(), + parent_version_id: Some(root.version_id.clone()), + project_revision: 5, + resource_bindings: Vec::new(), + created_reason: GameIterationVersionCreatedReason::AgentRevision, + created_at: 101, + }; + + validate_game_iteration_versions(&[root.clone(), child.clone()]) + .expect("valid version graph"); + + let mut invalid_child = child.clone(); + invalid_child.version_id = root.version_id.clone(); + assert!( + validate_game_iteration_versions(&[root.clone(), invalid_child]) + .expect_err("reject duplicate version id") + .contains("版本 ID 重复") + ); + + let mut invalid_child = child.clone(); + invalid_child.parent_version_id = Some("missing".to_string()); + assert!( + validate_game_iteration_versions(&[root.clone(), invalid_child]) + .expect_err("reject missing parent") + .contains("父版本必须先于子版本存在") + ); + + let mut invalid_child = child.clone(); + invalid_child.project_revision = root.project_revision; + assert!( + validate_game_iteration_versions(&[root.clone(), invalid_child]) + .expect_err("reject non-increasing revision") + .contains("projectRevision 必须大于父版本") + ); + + let mut invalid_child = child.clone(); + invalid_child.created_at = root.created_at - 1; + assert!( + validate_game_iteration_versions(&[root.clone(), invalid_child]) + .expect_err("reject time before parent") + .contains("createdAt 不能早于父版本") + ); + + let mut invalid_child = child.clone(); + invalid_child.project_revision = GAME_ITERATION_VERSION_MAX_SAFE_INTEGER + 1; + assert!( + validate_game_iteration_versions(&[root.clone(), invalid_child]) + .expect_err("reject unsafe project revision") + .contains("超出 JavaScript 安全整数范围") + ); + + let mut invalid_child = child; + invalid_child.resource_bindings = vec![ + GameIterationVersionResourceBinding { + slot_id: "player".to_string(), + resource_id: "asset-player".to_string(), + }, + GameIterationVersionResourceBinding { + slot_id: "player".to_string(), + resource_id: "asset-player-next".to_string(), + }, + ]; + assert!( + validate_game_iteration_versions(&[root, invalid_child]) + .expect_err("reject duplicate slot") + .contains("资源槽位重复") + ); } #[test] diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 4a993ab09..f2cfed4f3 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -538,6 +538,9 @@ export function ImageCanvasEditorView({ currentUser: authUi?.user ?? null, }); const refreshEditorWalletState = useCallback(() => { + if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) { + return; + } refreshEditorWalletBalance(); loadRechargeCenter(); }, [loadRechargeCenter, refreshEditorWalletBalance]); diff --git a/src/components/platform-entry/usePlatformProfileCenterController.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.test.tsx new file mode 100644 index 000000000..c57737032 --- /dev/null +++ b/src/components/platform-entry/usePlatformProfileCenterController.test.tsx @@ -0,0 +1,82 @@ +/* @vitest-environment jsdom */ + +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ProfileRechargeCenterResponse } from '../../../packages/shared/src/contracts/runtime'; +import { usePlatformProfileCenterController } from './usePlatformProfileCenterController'; + +const getPlatformProfileRechargeCenterMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../services/platform-entry/platformProfileClient', async () => { + const actual = await vi.importActual< + typeof import('../../services/platform-entry/platformProfileClient') + >('../../services/platform-entry/platformProfileClient'); + return { + ...actual, + getPlatformProfileRechargeCenter: getPlatformProfileRechargeCenterMock, + }; +}); + +const rechargeCenter: ProfileRechargeCenterResponse = { + walletBalance: 100, + mudPointBalance: { + totalPoints: 100, + permanentPoints: 100, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 0, + dailyFreeResetsAt: null, + }, + membership: { + status: 'normal', + tier: 'normal', + startedAt: null, + expiresAt: null, + updatedAt: null, + cycleStartedAt: null, + cycleResetsAt: null, + cycleGrantedPoints: 0, + cycleRemainingPoints: 0, + cyclePeriodDays: 30, + }, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, +}; + +describe('usePlatformProfileCenterController', () => { + afterEach(() => { + getPlatformProfileRechargeCenterMock.mockReset(); + }); + + it('aborts an unfinished recharge center read when the consumer unmounts', async () => { + let resolveRechargeCenter!: (center: ProfileRechargeCenterResponse) => void; + const pendingRead = new Promise((resolve) => { + resolveRechargeCenter = resolve; + }); + getPlatformProfileRechargeCenterMock.mockReturnValue(pendingRead); + const { result, unmount } = renderHook(() => + usePlatformProfileCenterController({ + activeTab: 'editor-canvas', + isAuthenticated: false, + showRechargeEntry: true, + requestLogin: vi.fn(), + currentUser: null, + }), + ); + + act(() => result.current.loadRechargeCenter()); + const requestOptions = getPlatformProfileRechargeCenterMock.mock.calls[0]?.[0]; + expect(requestOptions?.signal.aborted).toBe(false); + + unmount(); + expect(requestOptions?.signal.aborted).toBe(true); + + resolveRechargeCenter(rechargeCenter); + await pendingRead; + }); +}); diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 94c8d2f71..a3a0c966e 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -354,6 +354,16 @@ export function usePlatformProfileCenterController({ const pendingWechatRechargeOrderIdRef = useRef(null); const confirmingWechatRechargeOrderIdRef = useRef(null); const rechargeCenterReadRevisionRef = useRef(0); + const rechargeCenterReadAbortControllerRef = + useRef(null); + + useEffect(() => { + return () => { + rechargeCenterReadRevisionRef.current += 1; + rechargeCenterReadAbortControllerRef.current?.abort(); + rechargeCenterReadAbortControllerRef.current = null; + }; + }, []); // 中文注释:支持带邀请码 query 的直达场景,登录成功后自动打开兑换面板并复用同一套输入状态。 useEffect(() => { @@ -398,6 +408,8 @@ export function usePlatformProfileCenterController({ const applyRechargeCenter = useCallback( (center: ProfileRechargeCenterResponse) => { rechargeCenterReadRevisionRef.current += 1; + rechargeCenterReadAbortControllerRef.current?.abort(); + rechargeCenterReadAbortControllerRef.current = null; setIsLoadingRechargeCenter(false); setRechargeError(null); setRechargeCenter(center); @@ -407,16 +419,25 @@ export function usePlatformProfileCenterController({ const loadRechargeCenter = useCallback(() => { const revision = ++rechargeCenterReadRevisionRef.current; + rechargeCenterReadAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + rechargeCenterReadAbortControllerRef.current = abortController; setRechargeError(null); setIsLoadingRechargeCenter(true); - void getPlatformProfileRechargeCenter() + void getPlatformProfileRechargeCenter({ signal: abortController.signal }) .then((center) => { - if (revision === rechargeCenterReadRevisionRef.current) { + if ( + !abortController.signal.aborted && + revision === rechargeCenterReadRevisionRef.current + ) { setRechargeCenter(center); } }) .catch((error: unknown) => { - if (revision !== rechargeCenterReadRevisionRef.current) { + if ( + abortController.signal.aborted || + revision !== rechargeCenterReadRevisionRef.current + ) { return; } setRechargeCenter(null); @@ -425,6 +446,9 @@ export function usePlatformProfileCenterController({ ); }) .finally(() => { + if (rechargeCenterReadAbortControllerRef.current === abortController) { + rechargeCenterReadAbortControllerRef.current = null; + } if (revision === rechargeCenterReadRevisionRef.current) { setIsLoadingRechargeCenter(false); } From 8f19964e3f348d0cfb7880429b78b5a9975c4f0d Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 5 Aug 2026 21:28:40 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E5=AE=8C=E7=BE=8E=E5=83=8F=E7=B4=A0=E5=85=A5=E5=8F=A3=EF=BC=9B?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=E8=89=BA=E6=9C=AF=E5=8B=BE=E9=80=89=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=A1=A5=E6=8F=90=E7=A4=BA=E8=AF=8D=E7=BA=A6?= =?UTF-8?q?=E6=9D=9F=20(#124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 段舒康 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/124 Reviewed-by: 段舒康 Co-authored-by: Linghong Co-committed-by: Linghong --- .../references/requests-and-outputs.md | 6 +- .../genarrative-external-v1.openapi.json | 4 +- .../shared-memory/decision-log.md | 460 +++ docs/project-memory/shared-memory/pitfalls.md | 19 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 29 +- ...timeDB连接池租约Drop兜底与取消安全-2026-06-11.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 6 + ...片画布】撤销范围与操作提示方案-2026-07-17.md | 6 +- ...片画布结构化持久化与迁移回滚方案-2026-07-19.md | 16 + ...辑器】画板图标素材生成入口设计-2026-06-15.md | 5 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 3 +- scripts/check-module-runtime-artifact.mjs | 11 +- server-rs/crates/api-server/src/app.rs | 141 + .../crates/api-server/src/editor_project.rs | 2832 ++++++++++++++++- server-rs/crates/api-server/src/http_error.rs | 18 + .../api-server/src/modules/editor_project.rs | 13 +- server-rs/crates/api-server/src/state.rs | 42 + server-rs/crates/platform-image/src/lib.rs | 3 +- .../platform-image/src/pixel_art_snapper.rs | 102 +- .../spacetime-client/src/editor_project.rs | 387 +++ .../spacetime-client/src/module_bindings.rs | 30 +- ..._pixel_art_canvas_completion_input_type.rs | 19 + ...pixel_art_canvas_placeholder_input_type.rs | 20 + ...tor_pixel_art_result_persist_input_type.rs | 28 + ...or_pixel_art_result_persist_result_type.rs | 27 + ...or_pixel_art_result_persist_status_type.rs | 20 + ...r_pixel_art_result_preflight_input_type.rs | 22 + ..._pixel_art_result_preflight_result_type.rs | 16 + ...r_pixel_art_result_and_return_procedure.rs | 59 + ...r_pixel_art_result_and_return_procedure.rs | 62 + .../src/editor_project_storage.rs | 1553 ++++++++- ...CanvasEditorGenerationIntegration.test.tsx | 23 +- .../ImageCanvasEditorModel.test.ts | 908 +++++- .../image-editor/ImageCanvasEditorModel.ts | 684 +++- .../ImageCanvasEditorShellView.test.tsx | 1 + .../image-editor/ImageCanvasEditorTypes.ts | 25 +- .../image-editor/ImageCanvasEditorView.tsx | 114 +- ...ImageCanvasGenerationComposerView.test.tsx | 338 +- .../ImageCanvasGenerationComposerView.tsx | 109 +- .../ImageCanvasHistoryModel.test.ts | 6 + .../image-editor/ImageCanvasHistoryModel.ts | 2 + .../ImageCanvasQuickEditPanelView.tsx | 6 +- .../ImageCanvasRasterEditModel.ts | 9 + ...ageCanvasSelectedLayerToolbarView.test.tsx | 104 + .../ImageCanvasSelectedLayerToolbarView.tsx | 58 + .../image-editor/ImageCanvasStageView.tsx | 14 + .../ImageCanvasWorldView.test.tsx | 134 +- .../image-editor/ImageCanvasWorldView.tsx | 52 +- .../perfectPixelOperationStore.test.ts | 228 ++ .../perfectPixelOperationStore.ts | 203 ++ .../useCanvasGenerationDialogs.test.tsx | 161 +- .../useCanvasGenerationDialogs.ts | 68 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 196 +- ...ImageCanvasGenerationSubmissionWorkflow.ts | 81 +- .../useImageCanvasGenerationSurface.test.tsx | 1 + .../useImageCanvasGenerationSurface.tsx | 21 + .../useImageCanvasGenerationWorkflow.test.tsx | 2429 +++++++++++++- .../useImageCanvasGenerationWorkflow.ts | 1397 +++++++- .../useImageCanvasLayerCommands.test.tsx | 226 +- .../useImageCanvasLayerCommands.ts | 28 +- .../useImageCanvasProjectPersistence.test.tsx | 530 ++- .../useImageCanvasProjectPersistence.ts | 259 +- ...InlineGenerationPlaceholderExpiry.test.tsx | 401 +++ .../useInlineGenerationPlaceholderExpiry.ts | 140 + src/index.css | 12 + src/services/apiClient.test.ts | 87 + src/services/apiClient.ts | 181 +- .../editorMediaAssetUploadClient.test.ts | 22 +- .../editorMediaAssetUploadClient.ts | 14 +- .../image-editor/editorProjectClient.test.ts | 173 + .../image-editor/editorProjectClient.ts | 86 +- .../image-editor/editorRetryOptions.ts | 4 + 72 files changed, 14838 insertions(+), 660 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_completion_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_placeholder_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs create mode 100644 src/components/image-editor/perfectPixelOperationStore.test.ts create mode 100644 src/components/image-editor/perfectPixelOperationStore.ts create mode 100644 src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx create mode 100644 src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md index cbd26dfa7..fe62ee1ee 100644 --- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -138,10 +138,10 @@ Carry the current art spec in `generationInputs.artSpec` and reflect important c } ``` -The top-level `style` field is not the art spec's visual-style prose. It controls deterministic post-processing: +The top-level `style` field is not the art spec's visual-style prose. It appends a short server-side clause to the prompt sent to the provider and enables deterministic post-processing: -- Omitted, `null`, empty string, or `"none"`: disable post-processing without warning. -- `"pixelArt"`: enable pixel-art snapping for ordinary image generation, `kind: "character"`, and icon spritesheet generation. +- Omitted, `null`, empty string, or `"none"`: no clause is appended and no post-processing runs, without warning. +- `"pixelArt"`: append one short pixel-art line to the end of the prompt sent to the provider, and enable pixel-art snapping, for ordinary image generation, `kind: "character"`, and icon spritesheet generation. The line is appended, not substituted — the rest of your prompt is unchanged. For the exact per-kind wording, read the `style` field description in the OpenAPI document; it is the contract, and this guide deliberately does not copy it. - Unknown strings, or `"pixelArt"` on unsupported kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`: continue without style processing and return `warning.code: "unsupported-image-style"`. - Non-string JSON values: malformed request, HTTP `400`. diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 0d7a2692a..6c65f059f 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -2991,7 +2991,7 @@ "none", "pixelArt" ], - "description": "可选生成后处理风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理;pixelArt 仅支持普通图片(kind 省略)和 character。未知字符串或不支持该风格的 kind 按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。" + "description": "可选生成风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理,提交给 provider 的提示词与未带该字段时逐字一致;pixelArt 仅支持普通图片(kind 省略)和 character,会在提示词末尾追加一行像素风约束(普通图片为「画面为像素风格」,character 为「角色主体为像素风格」)并在回图后执行像素规整。未知字符串或不支持该风格的 kind 按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。" }, "size": { "type": "string", @@ -3405,7 +3405,7 @@ "none", "pixelArt" ], - "description": "可选生成后处理风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理;pixelArt 启用图标图集像素规整。未知字符串按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。" + "description": "可选生成风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理,提交给 provider 的提示词与未带该字段时逐字一致;pixelArt 会在提示词末尾追加一行「每个图标素材均为像素风格」并启用图标图集像素规整。未知字符串按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。" }, "model": { "type": "string", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 00b604029..a8fd39af2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -67,6 +67,18 @@ --- +## 2026-07-31 图集切片按需编码并批量确认持久化 + +- 背景:`2026-07-29 图集切片必须受前置容量和有界 CPU 保护` 收口了连通域数量与 CPU 并发,但切片仍在一次循环里全部裁剪并编码,最多 64 份 PNG 字节连同整张 RGBA 同时驻留内存;持久化又按切片逐个调用 procedure,N 片至少 2N 次写入外加一次 cohort 完成,任一片失败都会留下已确认的部分记录。手动拆分入口另有一处重复鉴权:`get_editor_project` 已经取回并定位了来源资源,随后仍走 `parse_editor_reference_image` 按注册 ID 再解析一次,触发全账号项目与素材库扫描。 +- 编码与内存决策:`platform-image` 把切片拆成 `prepare` 与 `encode` 两步,`prepare` 只计算带 padding 的裁剪边界并持有 `Arc`,`encode(index)` 被调用时才裁剪并编码单片。裁剪阶段累计 padding 后像素,超过调用方传入的上限即在任何编码前返回 `TotalCropPixelLimitExceeded`;api-server 传 `EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS = EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4`(`16777216` 像素),映射为 `422` 与 `crop-pixel-limit-exceeded`。编码与上传由 `buffer_unordered(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY)`(`2`)串起,同时最多两片 PNG 在内存中。 +- 准入决策:新增独立于既有 CPU 信号量的 `EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER`(`EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY = 2`)。手动拆分在创建下载客户端和发起下载**之前**取得该许可,许可覆盖「下载 → prepare → 逐片编码 → 逐片上传」整段,在进入 SpacetimeDB 批量调用前显式释放,避免数据库慢调用继续占用整张 RGBA。门限不可用返回 `503`、等待超预算返回 `504`,两者共用既有 `slice-processing-timeout` code。自动生成路径复用同一许可,但其源图此前已在内存中,该许可只保护解码与连通域阶段,不覆盖下载。 +- 持久化决策:新增 procedure `persist_editor_spritesheet_slice_batch_and_return`,在单个事务内依次确认每片的 asset object、可选项目资源、可选账号素材,并在存在 `group_task_id` 时一并完成 cohort;每次拆分请求只调用一次。批次上限 `EDITOR_SPRITESHEET_SLICE_BATCH_MAX_ITEMS = 64`,写入前校验数量与 `expected_asset_count` 一致、批内 `assetObjectId / objectKey / resourceId / assetId` 不重复、`source_resource_id` 指向的既有资源存在且同 owner 同 project;需要完成 cohort 的批次必须每项都创建素材。切片记录 ID 由 `(ownerUserId, taskId, 切片序号)` 经 SHA-256 确定性派生,重放得到相同 ID,且只有既有记录与新输入逐字段一致时才幂等复用,否则报幂等键冲突。 +- 鉴权决策:手动拆分不再调用 `parse_editor_reference_image`,直接用已随 owner-scoped 项目读取取得的 `source_resource` 取 objectKey,典型路径的 SpacetimeDB 调用从 3 次降为 1 次。作为替代,新增显式三重断言——项目属于当前 owner、资源属于当前 owner、资源属于当前 project——任一不符返回 `403`。结构断言禁止该区间再出现 `parse_editor_reference_image` 或 `list_editor_projects`。 +- 传输边界:新增 `build_editor_spritesheet_http_client(connect, request)`,下载与上传共用同一组常量 `EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT = 10s`、`EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT = 60s`。 +- 影响范围:`server-rs/crates/platform-image/src/generated_asset_sheets/`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/editor_project.rs` 及生成的 module bindings;图标图集手动拆分与自动拆分链路。新增 SpacetimeDB procedure 与输入输出类型,需要重新生成绑定。 +- 验证方式:`platform-image` 覆盖 prepare 不编码且 `Send + Sync`、并发编码多个 index 结果不变、累计裁剪像素在编码前拒绝;`api-server` 覆盖切片记录 ID 稳定且按 owner / index 分区、自动路径保留处理超时告警码、上传超时释放内存许可;`spacetime-module` 覆盖批次校验的完整 cohort、重复 objectKey、来源资源同 owner 同 project、部分 cohort 拒绝与重放只在内容一致时复用。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、本文件 `2026-07-29 图集切片必须受前置容量和有界 CPU 保护`。 +- 补记说明:本条为事后补写,记录提交 `cf1a02312` 已落地的行为,不改变其任何决策。 ## 2026-07-31 AI 游戏创作资源依赖图采用 Rust 只读拓扑与前端派生 SVG > 状态:其中资源卡 Pointer Move 拖动预览与局部更新验收已由 2026-08-03 mentor 最新决定暂缓;只读拓扑、SVG 派生展示、搜索与选择高亮合同继续生效。 @@ -5950,6 +5962,16 @@ - 扩展边界:新 Provider 可直接实现 core `ProviderAdapter` 并注册,不修改 core enum/match。`platform-llm` 当前 DTO 不支持的 tool role/result、toolChoice none/specific 和 reasoning minimal/x-high 在 adapter 转换层零网络失败关闭;工具调用仍以最终 response 为权威。 - 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.50。 +## 2026-07-30 已有静态图片增加免费一键完美像素化 + +- UI 决策:图片选中浮动工具栏的栅格处理顺序固定为 `裁扩 → 去除背景 → 完美像素`。完美像素只对当前活动的静态栅格图层一键执行,不打开参数面板;音频、视频、图片序列和 `character-animation` 不显示。请求期间按 layer id 禁用并显示 busy,首个 await 前用同步 ref 防双击重复提交;结果保留源图并在右侧新增同尺寸 PNG。 +- API 与执行边界:新增登录态 `POST /api/editor/images/pixel-art-snaps`,复用 `platform-image` 纯内存 snapper、进程级 CPU 并发 2 以及既有输入尺寸上限。该入口免费 inline,不调用外部 provider,不创建 `external_generation_job`,不打开或刷新任务侧栏,也不进入泥点扣费 / 退款;它与生成请求 `style="pixelArt"` 的 best-effort 后处理是两个契约。2026-07-31 修订:并发控制改为两层——端点级并发闸最大 4、等待队列上限 2048,必须在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层仍是共享的 CPU 并发 2。30 秒预算的起算点同时从「下载完成后」前移到 handler 入口,现在覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整全过程,而不再只是 CPU 排队加处理。该端点的 OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端,不再每次新建无超时客户端。来源解析同时对齐图集拆分:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图时,来源资源已随 owner-scoped 项目读取完成鉴权,改为显式断言 `resource.ownerUserId` 与 `resource.projectId` 后直接取用其 objectKey,不再做全账号项目与素材库扫描;两个字段指向不同图片直接拒绝,不退回扫描路径。跨记录 asset_kind 扫描随之省略,存储类型点查保留,动图仍由下载后的静态编码门禁按实际字节拒绝。 +- 媒体与归属:前端先创建关闭 composer 的右侧占位,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前项目布局;正式请求使用 `sourceImageSrc` 承载源图 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`。请求禁止 `data:` / `blob:`、signed URL 和普通外链。BFF 下载前必须将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属。 +- 失败与持久化:已有图片入口使用 strict 语义,只接受静态 PNG / JPEG / WebP,拒绝 GIF、APNG、动画 WebP 和非静态素材。strict 完全复用生成风格的 legacy profile、峰值估算、单轴步长补全、walker、采样与编码;唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,strict 与 legacy 行为及输出必须一致。读取、解码、输入校验、并发排队、像素规整或 PNG 编码失败 / 超时 / 不适用时,不保存原图副本冒充成功,不执行最终 OSS PUT,也不创建 asset object、project resource、账号素材或结果 layer。成功时只对最终 PNG 做一次 PUT,至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果以 `source_resource_id` 关联该资源,再由 `canvasCompletion` 写入至多一个右侧派生 layer;不保存逻辑低分辨率图、诊断图或前后对比图。 +- 非事务边界:strict 零写入只覆盖首个最终 PNG PUT 前的引用 / owner / 项目 / 类型 / 静态编码 / 元数据 / 网格适用性 / CPU 处理门禁。进入持久化后沿用现有 `OSS + asset object → project resource → editor asset → canvas completion` 非事务顺序,后段失败可能保留此前已确认对象或记录;不做删除补偿或 unsafe POST 自动重放,按 `task_id / object_key / resource_id` 读取权威快照排障,跨系统单事务留待独立 procedure 方案。 +- 占位删除与重试:completion 必须读取当前权威 dialog;若删除已先持久化,只跳过画布 layer / dialog 写回,不得使用请求中的旧 placeholder 复活图层,已经成功持久化的 project resource / 账号素材允许保留。若回包时本地占位已删除,前端不得应用完成快照或写历史;现有布局 CAS 没有 deletion tombstone,因此 completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口,绝对“删除意图胜出”留待 targeted delete / tombstone 方案。该路由是 unsafe POST,客户端不得配置 `EDITOR_REQUEST_RETRY_OPTIONS`;请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放,Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照,由用户显式决定是否再次执行。 +- 历史边界:成功加入画布时写一条 `perfect-pixel` 历史,中文标签为“完美像素”,并纳入新增结果保护;撤销不得让派生 PNG 消失。像素处理失败或 completion 因占位删除未落画布时不写该历史。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`。 ## 2026-07-31 game-chat 每条输出入聊天、试玩后收束与平台图集引用 - 背景:game-chat 的 ready response 之前只作为 transient stream 展示,专业 Agent 的 `final-reply` 只进入各自私有 conversation,刷新或事件 / 轮询重放时项目聊天可能丢失这些输出;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。 @@ -5966,6 +5988,45 @@ - 决策:game-chat 的 client-owned Runner 在活动任务期间禁止关闭客户端;关闭前复用既有 durable idle 真相源,避免另建 UI busy 状态。用户明确暂停/取消并达到 idle 后再退出,不能靠重启后自动重放未知 Provider 结果。 - 决策:规范 Agent reasoning 默认由角色职责分层,显式 per-Agent patch 优先;配置状态对外展示实际 timing/retry,避免全局文件、per-Agent resolver 与历史 run snapshot 混淆。 +## 2026-08-01 生成风格 pixelArt 同时约束提示词 + +- 背景:`style="pixelArt"` 此前只驱动 provider 返回后的确定性像素规整,完全不参与提示词拼接。但 `platform-image` 的 snapper 是几何对齐器——先检测网格步长再按格重采样;provider 交一张柔和渐变图时横纵两轴都检测不到步长,生成路径使用的 legacy profile 会退到 `min(width,height)/64` 统一网格兜底,产出的是马赛克而不是像素画。也就是原语义等于「随便生成什么,然后强行网格化」。 +- 决策:`pixelArt` 从「纯后处理风格」改为「提示词约束 + 后处理」。注入点固定在 `generate_editor_image_for_owner` 与 `generate_editor_icon_spritesheet_for_owner` 各自构造 `submitted_prompt` / `prompt` 的位置,包住既有 builder 的返回值,builder 签名与其既有输出契约不变。三个入口(登录态路由、外部 API v1、异步 job worker)都汇聚到这两个函数,一处注入全覆盖。`None` 必须原样返回原提示词。 +- 作用域按链路分三条措辞,不共用同一句:普通图片没有抠像底色,用「画面为像素风格」;角色形象与图标图集生成后都要按纯色抠像,绿幕底必须保持平整,分别用「角色主体为像素风格」和「每个图标素材均为像素风格」,都不得出现「画面」级别的像素化要求,否则与同一段提示词里既有的「纯色背景必须平整无纹理、无渐变」互相拆台。角色形象的提示词已禁止出现角色以外的场景内容,因此只点名角色;图标图集一张图内是多个彼此分离的素材,需要逐个点名。 +- 强度边界:实测只提「像素风格」效果已可接受,因此不注入网格密度、色板色数、抗锯齿等约束。约束句一律追加在提示词末尾并独立成行,不前置、不改写 builder 内部语句。`kind` 为 `spec / quick-edit / ui-design / publication-material` 时 `pixel_art_supported` 已把 `pixelArt` 降级为 `None`,注入对它们不生效;「修改图片」链路的 DTO 没有 `style` 字段,完全不受影响。 +- 反向提示词:同步从画布四个生图入口(普通图片、角色形象共用一条,UI 设计图,修改图片两个 provider 分支)的 negative prompt 中移除「低清晰度」——该词按字面否定低分辨率,与以低分辨率重采样为本质的像素风直接对冲。其余玩法(拼图、消除、跳跃、方洞、大鱼、吠叫、自定义世界场景图)的同名词条不动,本次只收口画布项目。 +- 持久化影响按链路不同,不能一概而论:`output_prompt` 初值是 `submitted_prompt`,但只有普通图片会保持到最后写入 `editor_project_resource` 的 prompt 列,该列因此从存用户原文变为存原文加一行约束句。角色形象链路的 `output_prompt` 在抠图成功后被无条件覆盖为 `"去除纯色背景"`,其原图 project resource 存的是 `role_setting`(用户原文),因此约束句在角色的任何 project resource 里都不出现。图标图集链路的原图 spritesheet resource 存工程化提示词(含约束句),透明结果存 `"去除纯色背景"`,自动拆分的切片存 `"自动拆分图集"`。不新增 OSS PUT、项目资源、素材记录或画布图层。 +- 角色链路的完整提交提示词是否留存取决于 provider:`persist_editor_provider_source_image` 写 asset object 元数据时用的是 `actual_prompt.unwrap_or(prompt)`,provider 未回 `actualPrompt` 时才存 `submitted_prompt`(含约束句),回了就存 provider 改写后的文本。因此 provider 回 `actualPrompt` 的场景下 `submitted_prompt` 在系统内一处都不落——外部 API 审计的 `request_payload` 只记 `promptChars` 字符数,没有提示词原文。排障时按 `object_key` 查 asset object 元数据只在前一种场景下有效。该行为与 `web/master` 逐行一致,属既有可观测性缺口,本次未改。 +- 响应体三条链路并不一致:普通图片和角色形象返回 `role_setting`(用户原文),前端显示不变;图标图集返回的是 builder 构造并追加约束句后的工程化 `prompt`,即调用方(含外部 API v1)能直接看到绿幕子句、间距要求和本次新增的像素约束。图标请求本身没有 `prompt` 字段(收的是 `iconDescriptions`),「返回用户原文」对它不成立。该响应字段行为同样与 `web/master` 一致,本次只是让被回传的模板多了一行。 +- 上述 prompt 列的写入规则全部是既有行为,与 `web/master` 逐行一致,本次未改动一行。但该列同时被用户侧素材库搜索(`buildAssetSearchValues` 把 `asset.prompt` 计入匹配项)和后台素材查询页读取,而它当前混着三种语义:用户输入、工程化提示词、以及派生步骤描述。由此带来的模板噪声污染搜索(角色模板含「绿幕」「纯色背景」等词)、派生产物按源提示词搜不到(透明图的 prompt 列是 `"去除纯色背景"`)等问题均为存量,需单独立项与原设计者对齐后再动,不在本次范围内。 +- 未覆盖:图标图集尚未约束各素材共用同一像素块大小(`estimate_step_size` 取全图相邻峰间距的第 30 百分位,块大小不一时步长估计会偏);角色形象提示词里既有的「严格基于图1的角色美术视觉规范的美术风格」与像素约束存在潜在冲突,未改写。两项都等实测。snapper 当前无任何日志,`resolve_step_sizes` 走检测还是走统一网格兜底在外部不可观测,注入效果暂时只能靠人工看图判断。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/openapi/genarrative-external-v1.openapi.json`。 + +## 2026-08-01 完美像素端点补齐保留审计字段剥离 + +- 缺陷:`POST /api/editor/images/pixel-art-snaps` 自新增之日起未调用 `sanitize_editor_client_generation_inputs`,只对 `generationInputs` 做了可序列化性校验(`serialize_editor_asset_metadata`)便原样写入 `editor_project_resource` 与 `editor_asset`。登录用户因此可以自行声明 `screenColorHex / mattingProvider / mattingModel`,让后台 raw mapper 看到伪造的处理元数据。该 sanitizer 与其余 14 个生产调用点(`editor_project.rs` 13 处覆盖普通图片、角色、图标图集、UI 设计、快速编辑、抠图、上传等,`external_editor_api.rs` 1 处)在此端点加入前就已存在,属于新端点漏配既有约定,不是设计取舍。补上本端点后生产调用点为 15 个。 +- 决策:在 handler 解析 payload 之后、任何 IO 之前调用同一个 sanitizer,位置与其余入口一致。这三个字段是服务端产出的处理事实——`screenColorHex` 由背景色决策写入,`mattingProvider / mattingModel` 由 `apply_editor_matting_metadata_to_generation_inputs` 在 bgfilter 实际执行后写入——一律不接受客户端声明。完美像素是纯几何规整、不抠图(`model = "Perfect Pixel"`、`provider = "Genarrative"`),任何 matting 元数据出现在这类记录上本身就是伪造。 +- 影响边界:只能污染攻击者自己的记录(`owner_user_id` 取自 access token,不可控),不构成越权、信息泄露或计费漏洞;该端点 `generation_cost_mud_points = 0`。危害限于按这些字段做的后台统计、排障与审计出现假数据。 +- 验证:sanitizer 单元测试 `editor_client_generation_inputs_cannot_forge_internal_audit_fields` 已覆盖字段剥离与其余字段保留;端点接线由 `explicit_pixel_art_snap_is_inline_strict_and_persists_only_after_processing` 的顺序断言钉住,`sanitize_editor_client_generation_inputs` 必须排在 `resolve_editor_pixel_art_processing_deadline` 及之后全部 IO 之前,被挪到 IO 之后会直接失败。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-01 完美像素按钮补齐素材类型保存门禁 + +- 缺陷:完美像素按钮自新增之日起未接入 `isPersistingAssetKind`,handler 也未复用 `persistingAssetKindLayerIdsRef` 同步守卫。把图层的非空 `assetKind` 改成另一个非空值后,在异步资源保存完成前点击该按钮,请求会同时带上新 `assetKind` 和旧 `sourceResourceId`,后端 `resolve_editor_pixel_art_snap_asset_kind` 检出请求类型与来源权威类型不一致直接返回 `400`。两道防护与相邻的拆分图集按钮在完美像素按钮加入前就已存在,属于新入口漏配既有约定。 +- 决策:完美像素按钮的 `disabled / aria-busy` 与拆分图集共用同一套门禁(`isPersistingAssetKind || isPerfectPixelProcessing`),handler 侧同样先查 `persistingAssetKindLayerIdsRef` 再提交——`disabled` 只挡下一帧,同步 ref 才挡得住 `setState` 生效前的那一次点击。 +- 无障碍:保存态名称不得直接复用拆分图集的「素材类型保存中」。`icon-spritesheet` 图层会同时渲染两个按钮,撞名后读屏用户无法区分控件,既有测试也会因 `getByRole` 命中多个元素而失败。完美像素改用与「完美像素处理中」同构的「完美像素等待素材类型保存」。 +- 影响边界:`400` 发生在纯函数前置校验阶段,此时尚无 OSS PUT 与资源创建,不写坏数据;用户可在资源保存完成后重试成功,代价是需要手动清理失败占位。仅当「非空类型改为另一个非空类型」时触发——权威类型为空时走兜底分支不比较。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-01 完美像素归属校验单次取数并纳入处理预算 + +- 缺陷:`POST /api/editor/images/pixel-art-snaps` 在合法 `assetId` 且不带 `sourceResourceId` 时,取得端点准入许可后到 OSS 下载之间会顺序执行 8 次 SpacetimeDB 调用,且全是裸 `await`,第一次真正应用 30 秒预算的是下载。其中 `list_editor_projects` + `get_editor_asset_library` 这对全账号扫描重复了三轮——`resolve_editor_reference_object_key_for_owner` 内部两个子函数各扫一轮,`resolve_editor_pixel_art_source_for_owner` 再扫第三轮,拉的是同一份数据。 +- 契约澄清:预算从 handler 入口「起算」不等于「覆盖」。此前文档写成覆盖 SpacetimeDB 读取是错的:deadline 是绝对时刻,早起算只让后续余额更少,中间不检查就不会在该阶段返回 `504`,请求会一路走到下载才失败并返回下载相关文案,误导排障。同时端点准入许可全程被这些无界调用占用,把并发闸自身变成瓶颈。 +- 决策一(去重):参照抠图入口 `resolve_editor_background_removal_source` 的既有范式——扫一次,注册 ID 解析、归属校验和跨记录 `asset_kind` 收集全部交给 `_from_records` 纯函数在内存里完成。`resolve_editor_pixel_art_source_for_owner` 自行取一次 owner 快照后复用,不再调用 `resolve_editor_reference_object_key_for_owner`;该包装是给没有任何上下文的调用方用的,保持不动。归属校验命中已登记记录即短路,只有两份记录都查不到时才回落 `ensure_editor_reference_asset_object_owned` 点查。全账号 RPC 由 6 次降为 2 次。整段合法 `assetId` 链由 8 次降为 4 次——`get_editor_project`、`list_editor_projects`、`get_editor_asset_library`,加上恒定执行的 `get_asset_object_by_location`(承担存储 taxonomy 的非静态门禁,成本与账号规模无关,两条路径都保留);objectKey 未登记在 owner 任何记录里时多一次 `ensure_editor_reference_asset_object_owned` 的点查,最多 5 次。该兜底分支上同一个 objectKey 会被点查两次(归属校验一次、存储门禁一次,参数相同),可合并但收益远小于已削掉的全账号扫描,暂不处理。 +- 决策二(预算):`get_editor_project` 到来源解析结束整体包进 `tokio::time::timeout_at`,超时返回 `504` 且文案指向归属校验而非下载。不重复写 `Instant::now() >= deadline` 预检——紧邻的 `acquire_editor_pixel_art_snap_permit` 已做该预检,成功即意味着未超预算,且本块首个 await 是网络 IO 不会立即就绪,不构成 `timeout_at` 先 poll 再判超时的陷阱。 +- 验证:顺序断言新增 `tokio::time::timeout_at(` 与超时文案;参照 `937378ab9` 的做法用 `assert_function_occurrence_count` 把 `resolve_editor_pixel_art_source_for_owner` 内的 `.list_editor_projects(` 和 `.get_editor_asset_library(` 各钉为 1 次,并用 `assert_function_not_contains` 禁止该函数重新调用取数包装。后者断言的是调用形式 `resolve_editor_reference_object_key_for_owner(state` 而非裸函数名,否则会命中生产代码里说明「老包装保持不动」的注释——该陷阱在编写时即由测试抓出。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-08-01 game-chat 首版四阶段快车道与美术硬门 - 决策:game-chat 首版只投影 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 四个阶段,页面进度显示 `x/4`;四个专业 Agent 的安全 `final-reply` 均逐条进入项目聊天,`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。完整 GUI / CLI 任务图仍保留原有节点和执行语义,内部 Provider / child loop 不作为用户轮次。 @@ -5978,6 +6039,40 @@ - 每条输出入聊天:事件文件中的原始 `summary / detail` 仍是私有 Runtime 证据,不可由前端直接持久化。Rust 只对白名单用户进度生成 `publicText`,同时为每次真实追加生成 `eventId`;action 重放沿用 action 身份,普通事件使用进程、毫秒与单调序列组成唯一身份。前端把 `eventId + publicText` 和四阶段专业 Agent 的 durable final reply 作为独立 assistant 消息,按顶层 `messageId` 幂等写入项目 conversation;重载恢复、轮询与实时事件并发不得重复或漏掉当前已观察输出。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。 +## 2026-08-01 完美像素未知结果先对账再定性 + +- 缺陷:`snapImageToPerfectPixels` 的 catch 对所有错误一视同仁——标 `failed`、`finally` 解锁、按钮恢复可点。transport 异常、abort 和 120 秒客户端超时因此被谎报成明确失败,而服务端此时很可能已经完成 OSS PUT、asset object、project resource、账号素材和画布回填,只是响应没回来。用户按提示重试就再造一整份对象、资源与素材。这直接违反本功能自己立下的契约:「结果未知时先 GET 权威项目 / 素材快照,由用户显式决定是否再次执行」。客户端未配 `EDITOR_REQUEST_RETRY_OPTIONS`(禁自动重放)这半条一直是达标的。 +- 判别依据:`ApiClientError` 只在拿到服务端 `Response` 时由 `buildApiClientError` 构造,transport 异常、`AbortError` 和 `TimeoutError` 在重试判定后原样抛出。因此 `error instanceof ApiClientError` 即「服务端明确响应过、结果已知」,其余一律按未知处理。已知结果不发对账 GET,避免每个 `400` 都多打一次权威读取。 +- 决策:未知结果先 `loadEditorProject` 取权威快照,再按占位是否存活分流。占位已被 completion 消费掉说明这次其实成功,按快照收口并写入正常的 `perfect-pixel` 历史,不报错。占位仍在说明画布没收到结果,同步快照消除本地与服务端偏差但**不写历史**,文案明确告知结果未知且素材库可能已有派生图、要求用户先核对再决定是否重试——持久化是非事务的,OSS 对象与账号素材可能已落库而画布回填未完成。对账 GET 本身失败时给出「权威快照读取失败」的独立文案,不退回谎报。 +- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口。占位在 POST 前已由 `flushProjectPersistence` 落库,`hydrateCanvasGenerationDialog` 原样恢复 `generating`,而该链路不进 `external_generation_job`,任务侧栏轮询看不到它,inline POST 的 Promise 随旧页面销毁。需要在 hydration 后加对账,且要先给 dialog 增加「属于无 durable job 的 inline 链路」标记,改动面大于本次,单独立项。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,调小可让对账更早发生,未处理。 +- 验证:新增三条用例分别覆盖「未知但实际成功→按快照收口并写历史」「未知且占位存活→只同步快照、标失败、文案要求先核对」「`ApiClientError` 已知失败→不发对账 GET、不动快照」。既有用例 `keeps a failed perfect-pixel placeholder` 原本用裸 `Error` 表达「服务端识别不到网格」,语义不准且会误入对账路径,改为 `ApiClientError`。测试 harness 新增 `dialog-error` 输出,否则对账文案不可观测。 +- 补充(同日):对账只对真正发出过 POST 的失败生效。占位创建、源图解析和 `flushProjectPersistence` 都在 POST 之前,它们失败时请求根本没发出,此时给出「素材库可能已存在派生图」是反向谎报,与本条要修的谎报是镜像关系;用 `perfectPixelPostAttempted` 标记划界,同时省掉一次无意义的权威读取。占位存活分支也不再调用 `applyProjectSnapshot`:传给该 hook 的是 `ImageCanvasEditorView` 的 `applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致(占位都在),套用只会覆盖用户在请求期间的未保存编辑。这次 GET 的用途是判定,不是同步。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-01 完美像素持久化阶段由服务端显式告知客户端 + +- 背景:上一版对账用 `error instanceof ApiClientError` 判定「结果已知」,即「服务端响应过就等于没落库」。这个等式不成立——持久化非事务,`complete_editor_canvas_generation` 走 CAS 写入,冲突时 `spacetime-module` 抛「图片画布版本冲突」,经 `map_editor_project_error` 变成 `409`;`403 / 404 / 400` 同理。也就是说带响应的 4xx 同样可能发生在 OSS 对象、asset object、project resource 和账号素材全部落库之后。完美像素要跑满 30 秒,用户在这期间改动画布把 revision 推进并不罕见,因此该场景触发频率高于最初估计。 +- 否决的两个方案:把所有 POST 后失败都当未知(每次常见校验失败多两次读取,且给不可能产生素材的场景附上「请核对素材库」的不适用提示);按状态码分类(`409` 确实只来自写操作,但 `403 / 404` 和 `5xx` 在持久化前后都会出现,分不干净,等于把猜测写进代码)。 +- 决策:由服务端显式告知。`AppError` 新增 `with_detail_field`,在已有 details 上补字段而不是像 `with_details` 那样整体替换,保留下游写入的 provider / message——客户端要靠 message 定位、靠新字段决策。`snap_editor_image_to_pixel_art` 在第一次 OSS PUT 之后的四条失败路径(账号素材持久化失败、项目资源缺失、账号素材缺失、画布回填失败)置 `resultPersistenceStarted: true`。客户端只对「完全无响应」和「带该标记」的失败做对账,常见的纯校验 `400`、排队 `503`、预算 `504` 既不多打读取也不附加提示。 +- 配套修复:对账成功分支补上 `hasCanvasGenerationDialogById` 检查——权威快照里占位消失有两种原因,服务端消费掉或用户在请求期间删除,后者契约要求不应用完成快照、不写历史,成功路径同一处早有这道检查而对账路径漏了。对账同时刷新素材库(新增可选 `refreshAssetLibrary` 贯穿 `ImageCanvasEditorView` → surface → workflow),否则只 GET 项目却让用户核对素材库,他看到的仍是旧列表,不满足契约的「项目 / 素材快照」。占位存活分支刻意不调 `applyProjectSnapshot`:传入的是 `applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致,套用只会覆盖未保存编辑。 +- 验证:服务端 `assert_function_occurrence_count` 把标记钉为 4 处,并用顺序断言要求它只出现在 `persist_editor_generated_image_owned` 之后——漏标一处或误标在校验阶段都会失败。客户端新增用例覆盖「带标记的 409 触发对账并刷新素材库」「未带标记的 400 不对账、文案保持服务端原文」「用户删除占位则不应用快照不写历史」。 +- 文案边界:对账尾句只下指令、不断言素材库已刷新。`refreshAssetLibrary` 在 `canAccessProtectedData` 为 false 时直接 return,读取失败也只在鉴权错误时弹登录框、其余一律吞掉,返回 `Promise` 不带成败信号,且它本身是可选 prop——三种情况下「已刷新」都是假话,会让用户对着旧列表判定「没有派生图,可以重试」,重新走回这条修复要避免的重复创建。刷新照旧调用(成功时用户白赚一份新列表),但文案在刷新失效时也必须成立。 +- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口(已由 2026-08-03 的 `requiresLiveSession` 条目解决)。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,未调整。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素刷新后的孤儿占位由归属标记收口 + +- 缺陷:完美像素在 POST 前 `flushProjectPersistence()` 把 `status: 'generating'` 的占位落库,随后走同步 HTTP。此时刷新页面,浏览器断连、handler future 被丢弃,服务端不会走完画布回填;新页面 hydrate 时 `status` 被原样还原(`isGenerationStatus` 认 `generating`),而加载期没有任何对账、轮询或重试 GET,占位就永久停在转圈状态。它还消不掉——Esc 被 `useImageCanvasKeyboardShortcuts` 的 `status === 'generating'` 挡,composer 关闭被 `closeCanvasGenerationComposer` 的同一判断挡。 +- 归因:序列化设施是存量,但这个组合是本分支首次出现。同类路径逐条比对——去除背景的 `EditorBackgroundRemovalResult` 的 `queueState` 是非可选字段,恒走 durable job,worker 会在服务端替换占位;拆分图集根本不创建占位;图片生成等提交类链路在默认 `ExternalGenerationMode::Queue` 下同样入队,只有显式设 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 的部署才同步执行。完美像素的 handler 353 行全同步,`tokio::spawn` / `enqueue_` 各 0 次,`EditorPixelArtSnapResult` 无 `queueState`,是唯一「持久化 generating + 无 durable job」的链路。 +- 方案取舍:不能从写入侧解决。`validate_editor_pixel_art_snap_placeholder_exists` 要求占位必须已落库,否则返回 `409`「完美像素画布占位不存在或尚未保存,请重试」——不持久化占位会让每一次完美像素都失败。POST 前那句 flush 正是为满足该门禁而存在。因此只能在读取侧收口,纯前端。 +- 决策:给 dialog 增加 `requiresLiveSession` 标记,加载时由 `dropDeadInlineGenerationPlaceholders` 剥离置位且仍为 `generating` 的占位。判据是结构性不变量而非时间阈值:这类占位的收口只能由创建它的会话完成,而活着的那一份始终在内存里、永远不经过快照 hydrate,所以凡是从服务端快照读回来的必然属于已死会话。因此不需要时间戳,也不必猜阈值。队列型占位一律不置位——它们的 job 在服务端继续跑,误清会让用户以为操作没发生而重复提交。hydrate 只认布尔 `true`,缺字段的历史占位按队列型处理,不会被误清。 +- 作用域:剥离只用在项目首次加载的两个调用点(会话缓存与权威快照都要,否则首屏会先闪一个永远转圈的占位)。**不能**下沉进 `hydrateCanvasGenerationDialog`——会话内 `applyQueuedEditorGenerationProject` 也会重新 GET 项目并套用,那时候占位对应的操作正在进行,套用剥离会把自己的活占位清掉。唯一置位点是完美像素占位的创建处。 +- 提示文案:服务端持久化顺序 `OSS PUT → asset object → project resource → editor asset → 画布回填` 是非事务的,加载时还看到 `generating` 只说明最后一步没做完,前面几步可能已成功。所以不能断言「什么都没发生」,只提示「画布占位已清理,请确认素材库是否已生成派生图」,与同链路的对账文案同一口径。计数用累计值而非布尔——同一会话可能连着切换多个项目,布尔只提示一次。 +- 已知残留:剥离是本地的,不主动回写。`applyProjectSnapshot` 会置 `skipNextProjectLayoutSaveRef`,加载后的第一次 effect 被消费掉,所以清理要等用户下一次布局变更才随防抖落库;在此之前重复打开会重复提示。刻意不强制回写:那会加剧多标签页问题——B 标签加载时会误判 A 标签正在跑的占位为孤儿,只在本地剥离时 A 的回填仍能成功,一旦立即回写就会让 A 撞上 `409` 占位不存在。 +- 更正(2026-08-03):上一条里「多标签页另有 CAS `expected_revision` 兜底」是错的。CAS 挡的是基于陈旧 revision 的覆盖写,而 B 是以**当前** revision 写入一份合法布局,必然放行。触发也不需要 B 去点完美像素——B 加载后任何布局改动都会触发防抖保存,把「不含 A 占位」的布局写回服务端。后果已核到底:`complete_editor_canvas_generation` 在占位缺失时不报错,走 `Ok(None)`,A 的响应是 200 且 `project: null`,A 的前端据此移除本地占位并提示「完美像素结果已保存到素材库,画布占位已不存在」。所以 A 的 OSS 对象、项目资源、素材库记录三样都在,用户也被准确告知,丢的只是画布自动落位,需手动从素材库拖回。定级 P3,真正的修法是给占位加会话归属标识、只允许创建者剥离,单独立项。 +- 验证:`dropDeadInlineGenerationPlaceholders` 四条单测覆盖「剥离已死 inline 占位」「保留队列型占位(缺字段与显式 false 两种)」「保留已终态的 inline 占位与普通图层」「标记经 hydrate 与序列化往返不丢失」——最后一条钉住白名单式 hydrate 漏字段会让标记在一次「加载→保存」后消失。工作流测试新增 `live-session-dialogs` 探针,正向断言完美像素占位置位、反向断言去除背景占位不置位。`vitest src/components/image-editor` 893 通过 / 72 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-08-03 game-chat 开发态前后端同源与快车道恢复 - 启动决策:`npm run agc` 与 `npm run agc:game-chat` 统一先经外层 Node 启动器预检 `3080`。在 marker 尚不能证明 worktree 归属时,任何已占用的 3080 都不得复用,并必须在原生窗口创建前失败关闭;Tauri CLI 退出后必须收束已启动的客户端进程树,不允许终端已退出但窗口与 Runner 仍假在线。 @@ -5997,6 +6092,61 @@ - Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。HTML tokenizer 只把 TAB / LF / FF / CR / SPACE 视为标签空白,并按 `type / language / nomodule` 判断 Chromium 中的可执行脚本;静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script、不可达匿名或命名函数、短路表达式、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断实际控制且绑定未被局部变量或函数参数遮蔽的正式棋盘。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图只按显式 export/import binding 传递语义,side-effect import 不暴露被导入模块的局部绑定,ASI 换行与 template `${...}` 内真实 import 仍参与依赖解析;文件按去重数量和累计 2 MiB 上限有界读取,对象属性 `import / from` 与控制块后的正则正文不得伪造依赖。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中保留点击前基线,MutationObserver 只冻结 trusted 输入 listener 同步产生的最后状态,CDP 点击返回后再收口该冻结值,因此后注册的同步 click listener 仍会计入,而 RAF / timer 任务不会污染因果证据。页面全局对象不能改写隔离世界证据,capture-phase、stopPropagation 与真实 window bubble 处理器均应正确验收。探针 fingerprint 必须覆盖 install、ready 与 finish 三段真实脚本。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算 fingerprint 并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。 - 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 +## 2026-08-03 完美像素对抗性审查第一批修复 + +- 范围:本分支相对 `web/master` 的 17 条审查发现里,只修其中三条——它们互相独立、改动小、无需设计决策。其余按链路分批,队列化改造(把完美像素接进 `enqueue_editor_generation_job`)因改动面超出本分支预期而未采纳。 +- A1 持久化标记漏洞:`snap_editor_image_to_pixel_art` 对 `persist_editor_generated_image_owned` 用的是裸 `?`,而该 helper 内部顺序是 `PUT → HEAD → confirm_asset_object`。HEAD 或 confirm 失败时 OSS 对象已存在,错误却不带 `resultPersistenceStarted`,客户端的 `outcomeMayBePersisted` 因此为假、对账根本不执行,直接报普通失败;用户重试会用新 `task_id` 生成新 object key,首个对象成为无从发现的孤儿。这是三条里唯一会让收口机制完全不触发的。 +- A1 的标记边界:标在 helper 内部而不是调用点——调用方拿到的是同一个 `AppError`,无法自行判断内部走到了哪一步。边界取在第一次 PUT:`prepare_put_object` 与「OSS 未配置」这两处失败都在 PUT 之前,标了会让客户端对着什么都没落库的失败去核对素材库,是与本条镜像的反向谎报。PUT 自身也标——响应丢失时字节可能已落盘,属于契约要覆盖的未知结果。共四处:PUT、HEAD、asset object 入参构造、`confirm_asset_object`。该 helper 为 9 条编辑器持久化流程共用,新增的 details 字段对其余调用方语义同样成立(持久化确实已开始),只是目前只有完美像素前端消费。 +- A3 失败反馈第三态:catch 尾部只处理「有占位」与「没有 dialogId」,缺「有 dialogId 但占位已被删」。删除生成中占位是产品支持的流程(`requestRemoveCanvasGenerationDialog` 对 `generating` 会先弹确认),用户删完之后请求才失败时,`errorMessage` 被算出来又整段丢弃,界面零反馈。丢的不只是失败提示——对账得出的「请确认素材库是否已生成派生图」在同一句里,用户会在毫不知情的情况下重试。改为 `else` 兜底走全局提示。兄弟路径 `split-atlas` 无条件 alert、`remove-background` 直接 rethrow,都不存在这个第三态。 +- E1 测试并行竞态:`pixel_art_snap_permit_reports_exhausted_budget_without_waiting` 对进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 做绝对断言 `== 0`,而相邻用例会在自己的作用域里持有两个 guard,Rust 测试默认并行,两者撞上就随机变红。当时改为 before/after 相对断言,但后续第三批确认两次 load 之间仍可被并行用例插入,该方案未修复竞态并已撤回。过期预算用例只应断言 `504`;guard Drop 由相邻独立用例负责。 +- 验证方法:两条修复都先回退生产代码确认测试变红,再恢复。前端新用例在缺 `else` 分支时超时失败;服务端守卫在去掉任一处标记时报 `left: 3, right: 4`。首次验证时跑错了测试名——`explicit_pixel_art_snap_is_inline_strict_and_persists_only_after_processing` 里已有一组同名断言(钉的是 handler 内四处),新加的这组在 `editor_matting_releases_source_buffers_at_oss_boundaries`,两者同名不同域。 +- 验证结果:api-server 676 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致);`vitest src/components/image-editor` 894 通过 / 72 文件;`cargo fmt --check`、typecheck、eslint、`check:encoding` 通过。 +- 未修(已立项):A2 客户端 120s 早于服务端最坏合法时长(约 270s);B1 并发闸许可跨越无预算的持久化阶段;C1/C2/C3 `requiresLiveSession` 链路;D、E 组其余清理项。E1 当时仅改成相对断言,后续第三批重新打开并完成修正。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素静默路径收口(审查第一批续) + +- 背景:第一批只修了失败尾部那一处静默(`else` 兜底)。复审指出对账分支还有一处早返回同样不说话,追查后发现它有个孪生分支在成功路径上,两处形状一致——操作其实已经落库、用户删掉了占位、没人告诉他素材库多了一份。 +- 决策:提示与「移除占位」解绑。成功路径 `!result.project` 分支原先把提示写在 `if (hasCanvasGenerationDialogById(...))` 里面,占位不在就整段静默;改为无条件提示、条件移除。对账分支 `!placeholderSurvived` 且本地占位也没了时,不应用快照仍然正确(删除意图胜出),但要补同一句提示——走到这里意味着权威快照里占位已被 completion 消费,本分支下一步正是据此把结果当成功套用,结论一致:结果已落库。 +- A4 的取舍:会话缓存里剥掉的占位数**不能**直接并入提示计数。完美像素成功后 `applyProjectSnapshot` 会置 `skipNextProjectLayoutSaveRef`,加载后的第一次 effect 不落库,所以会话缓存可能停留在完成之前的版本;重新加载时缓存里那个陈旧占位被剥掉、计数加一,而权威快照其实是成功的,并入就会报一条「上次处理未完成」的假告警。改为单独计数,只在权威加载失败、没有第二个来源可以纠正这幅画面时才提示。 +- A4 的可观测性核实:`isProjectReady` 只被启动意图消费和自动保存 effect 使用,不参与画布渲染门禁,所以权威加载失败时画布照常显示,用户看到的确实是一张静默少了占位的画布,提示有必要。鉴权失败与项目失访两条路径各自弹窗或跳转,不在这里重复打扰——为此在 `replaceAppHistoryPath` 后补了 `return`,该分支原本就没有后续语句,行为不变。 +- 验证:新增用例覆盖「对账发现两侧占位都没了 → 仍提示素材库结论」,去掉提示后该用例失败。`vitest src/components/image-editor` 895 通过 / 72 文件,typecheck、eslint 通过。 +- 未覆盖:A4 没有专用测试。`readEditorProjectSessionCache` 是持久化 hook 内的局部函数而非可 mock 的模块,要测得在 jsdom 里按缓存键格式播种存储再让权威加载失败,成本高于这三行改动本身;改动本身是「捕获计数 + 失败分支上报」,无分支逻辑变化,暂按未覆盖记录。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素持久化阶段纳入预算,服务端最坏时长收进客户端超时 + +- 缺陷:处理预算(30 秒)只覆盖到规整为止,持久化阶段完全无界,仅受 OSS 客户端每请求 120 秒约束,而 PUT 与 HEAD 各自独立计时,再加三次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上,远超客户端 `snapEditorImageToPixelArt` 的 120 秒。客户端因此会在服务端仍在合法工作时先 abort:对账虽然照常执行(abort 不是 `ApiClientError`,`outcomeMayBePersisted` 为真),但它采样的是一个仍在途的操作——占位还在、`confirm_asset_object` 未跑完所以素材库还空,用户照提示核对什么也看不到,重试就用新 `task_id` 造出孤儿 OSS 对象。 +- 决策:给持久化整段套独立预算 `EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION = 60` 秒,用第二个 `tokio::time::timeout_at` 包住从 `persist_editor_generated_image_owned` 到 `complete_editor_canvas_generation` 的全部写入。服务端最坏 30 + 60 = 90 秒,落在客户端 120 秒内并留 30 秒余量给网络往返与计时精度。 +- 为什么独立起算而不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,因下载慢而被砍预算、中途放弃只会留下孤儿对象。取 min 会让「下载越慢、越容易留孤儿」,方向正好反了。 +- 超时必须带标记:这条超时发生在 PUT 已经发出之后,对象可能已落盘也可能没有,正是 `resultPersistenceStarted` 契约要覆盖的未知结果。不带标记客户端会判成确定失败、直接诱使用户重试。handler 内该标记的钉定计数因此由 4 升为 5,注释同步说明第五处是什么——这个升级由既有守卫自己报出来(`left: 5, right: 4`),不是事后补记。 +- 验证:新增 `pixel_art_server_worst_case_fits_inside_the_client_timeout` 钉住跨端不变式,把两侧数值和 30 秒余量都写死;顺序守卫新增「预算在前、写入在后」与超时文案 + 标记两项,任何把 persist 挪到 `timeout_at` 之前的改动都会失败。把持久化预算临时调到 120 秒可确认该测试变红。api-server 677 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。 +- 未覆盖:跨端不变式靠常量断言维系,客户端那侧的 120 秒仍是 `editorProjectClient.ts` 里的字面量,改动它不会让 Rust 测试失败。真正的双向钉定需要共享契约常量,本次未做。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素孤儿占位判据由结构不变式改为有界时间窗 + +- 缺陷:`dropDeadInlineGenerationPlaceholders` 原先依赖一条结构性不变式——置位 `requiresLiveSession` 的占位其收口只能由创建它的会话完成,而活着的那份始终在内存里、永不经过 hydrate,所以从服务端快照读回来的必然属于已死会话。这条在单标签页下成立,多标签页下是假的:B 标签打开同一项目会 hydrate 到 A 标签正在用的活占位,据此剥离,再由 B 下一次布局保存以**当前** revision 合法写回,把 A 的占位删掉。 +- CAS 的作用要说准:它挡的是基于陈旧 revision 的覆盖写。B 在 A 完成**前**写入时 revision 是当前的,CAS 放行——这是有害的那一半;B 在 A 完成**后**写入时 revision 已陈旧,CAS 拒绝——所以「B 把 A 的成品图层写没」这种更严重的情况本来就不会发生。此前 decision-log 笼统写「CAS 兜底」是错的,纠正后也不应反过来说 CAS 完全无用。 +- 决策:判据改为有界时间窗,只有超过 180 秒才判定为孤儿。窗口上界由两侧共同封死——服务端最坏合法时长是处理 30 秒加持久化 60 秒(都由 `timeout_at` 强制,见同日持久化预算条目),客户端整个 POST 又被 120 秒超时封顶;120 秒之后客户端必已 abort 并把占位改成 `failed` 或移除。取 180 = 120 客户端上限 + 60 余量(网络往返、标签页挂起后的时钟漂移)。 +- 关键依赖:这个方案在持久化预算落地**之前**不成立。那时服务端最坏时长无界,任何时间窗都是拍脑袋;把最坏时长收进 90 秒之后,时间窗才有硬依据。 +- 复用既有字段:时间戳用 `generationStartedAt`,由 `withGenerationTimestamps` 在占位进入 `generating` 时自动打戳,已序列化、已 hydrate,无需新增字段。 +- 撤回先前方案:此前多次记录「真正的修法是给占位加会话归属标识、只允许创建者剥离」。该方案不成立——B 拿到一个不同的会话 id,推不出 A 是死是活,照样只能猜。会话 id 只能识别「不是我的」,不能识别「已经没人要了」。 +- 兜底方向:缺 `generationStartedAt` 时按可剥离处理。实践中不会出现(两个字段同一次创建一起写),但按「保留」会让这类占位永久留在画布上,按「剥离」最坏只是退回引入时间窗之前的行为。 +- 时钟:取读取方的 `Date.now()`。同机多标签共享时钟,正是要修的场景,判定精确;跨设备有偏移风险,但此前是无条件剥离,任何时间窗都不会比原行为更差。 +- 已知残留:A 真死了而用户在 180 秒内重新加载时,占位会继续转到窗口过后的下一次加载才清掉。可以加客户端定时器在剩余时间后自行收口,但要多一套定时器生命周期管理,先不加,观察实际是否困扰。 +- 验证:新增四条用例覆盖窗口内保留、边界包含式、超窗剥离、缺时间戳兜底;去掉时间窗判断后其中两条变红。`vitest src/components/image-editor` 899 通过 / 72 文件,typecheck、eslint 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素并发闸竞争路径与 snapper 错误分档补测 + +- 背景:审查留下的两处覆盖缺口。并发闸的三条竞争路径(队列满 503 + `retry-after`、等待槽位超时 504、信号量关闭 503)此前零测试——`retry-after` 在整个 crate 里只出现在生产代码一处;`map_editor_pixel_art_snapper_error` 的四档状态码只有 `GridNotDetected → 422` 被间接覆盖。 +- 测试方式的取舍:不去把全局信号量或队列计数打满。两者都是进程级 `static`,在测试里填满会让并行跑的其他用例连带失败——这与当时误以为已修掉、后续第三批才真正纠正的 E1 属于同类竞态,不能一边修一边再造一个。改为把三条路径的错误各自抽成构造函数,直接断言状态码、文案和 `retry-after` 头;「哪条路径用哪个构造函数」由 `snap_editor_image_to_pixel_art` 的既有顺序守卫钉住,CAS 边界本来就有本地计数器的用例覆盖。 +- 覆盖边界要说清:这样覆盖的是错误形状与分档,不是端到端的竞争行为。真要覆盖后者需要把限流器与队列计数改成依赖注入,改动面超出补测本身,未做。 +- 分档的双向后果写进了断言注释:把用户上传的坏图(`Decode`)报成 500 会让客户端当服务端故障去重试;把服务端自身失败(`Encode` / `Processing`)报成 400 又会让用户以为是自己的输入有问题。另断言底层文案原样带上,否则「识别不到网格」与「解码失败」在用户侧无法区分。 +- 验证:删掉 `retry-after` 或把 `Decode` 改判 500,两条新用例分别精确变红。api-server 679 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导 - 决策:`/api/external/v1/mcp` 缺少、格式错误或无法验证 Bearer API Key 时继续返回相同 HTTP `401`,并增加 `WWW-Authenticate: Bearer realm="genarrative-external-editor"` 与机器可读 `details.guide`。引导只说明 Bearer Header 格式、登录后在「开发者 API Key」创建密钥、原始密钥只显示一次、凭据不得进入聊天或仓库、配置后重试 `initialize`,以及公开 manifest、Skill 与 OpenAPI 地址。 @@ -6026,6 +6176,179 @@ - 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。 - 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。 +## 2026-08-03 完美像素对账判据改看 dialog 收口状态,网关合成响应归入未知结果 + +- 缺陷一(对账把真成功判成失败):对账用「同 ID 的 generation-dialog 是否还在权威快照里」判定成败,而服务端成功回填时**保留**该 dialog 并就地改写——`apply_editor_canvas_generation_items` 置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`,该行为另有服务端测试断言 `dialog["generatedLayerId"]` 钉住。所以响应丢失但服务端其实已完成时,判据反向:用户被告知「画布未收到完美像素结果,请确认素材库」,而结果早已在画布上,重做一遍就造出第二份;这条分支还刻意不套用快照,本地也看不到那个新图层。 +- 逃过测试的原因要单独记:那条「未知但实际成功」的用例夹具写的是 `layers: []`,是服务端永远不会产生的形状。**测试不是漏了,是主动为错误判据背书**——用一个假前提把反向逻辑测成了正确的。修复顺序因此定为「先改夹具、看它变红、再改判据」,让这件事显式暴露一次而不是被新判据顺手掩盖。 +- 决策一:判据改看 `status` / `generatedLayerId`,并复用既有语义。`projectHasUnresolvedGenerationDialog` 早就是本仓库对「这个生成收口了没有」的定义,只是原先埋在队列轮询里;原始 record 查询现由收集全部同 ID 记录的 `findCanvasGenerationDialogRecords` 与 `isUnresolvedCanvasGenerationDialogRecord` 两处共用,不自创新判据——自创正是本次出错的起点。取原始 record 而不 hydrate:hydrate 会给缺失 status 补 `idle`,把「服务端没写」和「服务端写了 idle」混成一种。 +- 三态处置:dialog 不存在 → 占位在处理期间被删(本会话或另一标签页),completion 返回 `Ok(None)`,资源与素材已落库但快照不含结果图层,清本地占位并提示素材库,**不套用快照**(此前会套用一份不含结果的快照并写受撤销保护的历史,用户既看不到结果也撤不回);dialog 在且未收口 → 画布确实没收到,只给文案不同步;dialog 在且已收口 → 真成功,套用快照并写 `perfect-pixel` 历史。前两态都保留「用户已删本地占位则删除意图胜出」的检查。 +- 缺陷二(网关合成响应被当确定失败):分类前提是「拿到 `ApiClientError` ⇒ 服务端明确表态过 ⇒ 结果已知」。该前提对 Pingora 自造的错误体不成立——它只有 `code` / `message`、没有 `details`,因此既不是 transport 异常也拿不到 `resultPersistenceStarted`,直接跳过对账;而 `ConnectTimedout / ReadTimedout / WriteTimedout → 504`、`ErrorSource::Upstream → 502` 都可能发生在 api-server 已完成 OSS PUT 之后。 +- 决策二:新增 `isGatewayUnknownOutcomeError`,放在 `services/apiClient.ts` 而不是 image-editor——网关在所有接口前面,任何有副作用的 inline 写接口都有同一问题。只收 `GATEWAY_UPSTREAM_ERROR` / `GATEWAY_UPSTREAM_TIMEOUT` / `GATEWAY_PROXY_ERROR` 三类。`GATEWAY_RATE_LIMITED` / `GATEWAY_CONCURRENCY_LIMITED` / `PAYLOAD_TOO_LARGE` 是在网关就被拒、根本没到应用,属于确定失败,收进来会让普通节流也弹出「请核对素材库」,变成与本条镜像的反向谎报。 +- 两条共性:都是用代理信号代替事实——用「占位在不在」代替「操作完成没有」,用「有没有 HTTP 响应」代替「应用层有没有表态」。两处都是没有去读被代理的那个事实的真实形状。 +- 验证:新增四条用例(网关 504 触发对账、网关 429 不触发、应用层无标记 502 不触发、快照无 dialog 时不套用快照)。同时破坏两处修复后,四条精确变红。后两条是对照用例,专门守住「放宽判据不得退回反向谎报」。`vitest src/components/image-editor` 907 通过 / 72 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素链路复查:补齐第三处静默分支并区分两条结果文案 + +- 背景:对账判据与网关分类修完之后做的整链复查,目标是找遗留与新引入的问题,不是重复已知项。 +- 遗留(第三处静默):成功路径拿到 `result.project` 后,若本地占位已被用户删除则直接 `return`。不套用快照是对的(删除意图胜出),但什么都不说。此前已修过同一形状的两处(`!result.project` 分支、对账早返回),这是第三处,复查才发现。既有用例 `does not apply a completed project after the perfect-pixel placeholder was deleted` 只断言「不套用」,没断言「要说话」,所以也没挡住。 +- 新引入(文案混用):上一次修复让「对账发现已收口 + 本地占位已删」这一支沿用了「结果已保存到素材库,画布占位已不存在」。**两种情况的事实不同**——快照里没有 dialog 时服务端 completion 返回 `Ok(None)`,画布上确实没有结果图层;而 dialog 已收口时服务端画布上**有**结果图层,只是本地按删除意图没套用,重新加载即可见。用前一条文案会让用户以为画布上没有,再做一遍,正是本链路要消除的重复创建。 +- 决策:两条文案抽成常量并按事实分派——`PERFECT_PIXEL_ASSET_ONLY_NOTICE` 用于「服务端画布也没有」,`PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE` 用于「服务端画布已有、本地未同步」。四个使用点各归其位。 +- 测试补位过程值得记:第一次回归验证只有 1 条变红——说明「对账已收口 + 本地已删」这条分支根本没有用例,文案改动是无覆盖的。补上该用例后再破坏,2 条同时变红。**如果止步于第一次验证,就会把一处无覆盖的改动当成已验证。** +- 复查中核过、确认不是问题的两点:其一,网关放宽的作用域正确——`/api/*` 走 `is_generic_api_proxy_path`,读超时默认 `3600` 秒,远高于服务端 90 秒预算与客户端 120 秒,次序是 `90 < 120 < 3600`,网关不会在服务端合法工作期间截断,它合成 502/504 只可能是连接失败或进程不可用,确属未知结果;唯一变数是有人把 `GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API_READ_TIMEOUT_SECONDS` 调到 90 秒以下。其二,`projectHasUnresolvedGenerationDialog` 由 `some(...)` 改为「取首个匹配再判」存在极低风险的语义收窄,同 id 多 dialog 时行为不同,但 id 唯一,实际不可达。 +- 验证:`vitest src/components/image-editor` 908 通过 / 72 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 inline 占位补到期清理,收掉存活窗口引入的回归 + +- 缺陷:存活窗口把「立即刷新也能清掉孤儿占位」这条旧行为换掉了。剥离只在项目首次加载执行一次,窗口内被保留的占位再没有任何东西会重新判定——页面保持打开就一直转,必须等到用户下一次加载且距创建已满窗口才收口。这是引入 TTL 时的已知取舍,本次补上。 +- 复查中发现的关键事实:**手动收口入口本来就存在**——`requestRemoveCanvasGenerationDialog` 已接入键盘快捷键,对 `generating` 占位会先弹确认再删。所以缺的从来不是删除手段,而是「它已经死了」这个信号;占位看起来和正在干活一模一样。 +- 决策:加一次性到期定时器,到点走与加载期**完全一致**的处置——移除 + 同一条文案(抽成 `DEAD_INLINE_PLACEHOLDER_NOTICE` 共用)。未采纳「标记 failed 而不移除」:`failed` 会被自动保存持久化,而剥离只处理 `generating`,那张卡片会跨刷新长期存在,与当初选「刷新后占位消失」的用意相反,把一次性噪音变成永久残留。 +- 三条必须守住的实现约束,都写进了 hook 的文档注释:其一,到期回调只推进 tick 让 effect 重跑,判定始终在 effect 体里用当前 dialogs 和当前时间做——挂上定时器之后占位可能已被拥有者会话正常收口,按闭包旧值行动会清掉一个已完成的占位;其二,清理用底层 `removeCanvasGenerationDialogById` 而不是 View 的 `removeCanvasGenerationDialog`,后者是用户主动删除的语义(写 `delete-generation-result` 历史、清空选中、切回选择工具),自动清理记用户没做过的历史、抢走当前选中态都是错的,加载期剥离同样不做这些;其三,本会话自己在途的占位不会被误清(客户端 120 秒就 abort,catch 会把它推离 `generating`),但不依赖该推理,靠第一条的重新判定兜住。 +- 作用面划分:`dropDeadInlineGenerationPlaceholders` 跑在**快照**上、只在加载时执行;`collectExpiredInlineGenerationDialogIds` / `resolveNextInlineGenerationDialogExpiryAt` 跑在**内存 dialog** 上、供页面打开期间使用。同一条规则、两种数据形状,边界(到期时刻含等号不算过期)与缺时间戳的兜底方向都保持一致。 +- 到期时刻可精确计算,所以挂一次性定时器而不是轮询;多给 50ms 余量,避免贴着到期时刻醒来判定为未到期、白白多挂一轮。 +- 验证:纯函数四条用例覆盖边界、队列型与已终态不到期、缺时间戳立即到期、最早到期时刻;hook 四条覆盖到期清理、醒来重新判定(占位期间被收口则不清)、队列型永不挂定时器、已超窗立即清理。去掉定时器重挂逻辑后第一条变红。`vitest src/components/image-editor` 916 通过 / 73 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 到期清理链路复查:一个被证伪的假设与它留下的用例 + +- 复查怀疑:到期定时器要熬三分钟,而 `canvasGenerationDialogs` 变动很频繁(提交工作流三十余处变更点,队列型生成轮询期间持续更新状态)。把 effect 依赖挂在数组身份上,看起来会被无关变动不断重挂定时器、永远等不到触发,整个机制静默失效。据此把定时器改挂在计算出的到期时刻上,并加 ref 读取当前 dialogs。 +- 结论:**假设是错的**。为它写的回归用例(每秒一次无关变动、持续到超窗)在改回数组依赖后仍然通过——数组身份变化会让 effect 重跑,而 effect 体每次都重新判定到期,频繁变动带来的是更频繁的判定,不比定时器差;不变动时数组稳定,定时器正常存活。两条路径都收口。 +- 处置:撤回 `useMemo` + `useRef` 的改动,回到更简单的数组依赖版本——既然简单版本本来就正确,多出来的间接层没有收益。用例保留,但注释改写为它**实际证明**的性质:判定必须留在 effect 体里;将来若把它挪出去(例如只在定时器回调里判定),这条不变式才会真的失效,用例届时会变红。 +- 记这一条是因为过程本身有价值:先假设、再写用例、用例证伪假设、据此撤回改动。若跳过验证直接保留那次「修复」,就会在没有缺陷的地方永久留下一层多余的间接。本次会话里同类错误(凭推断得出结论而不验证)已出现多次,这次是验证挡住了。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 外部 API skill 文档同步 style 的提示词注入语义 + +- 缺陷:`references/requests-and-outputs.md` 把 `style` 描述为「只控制 deterministic post-processing」,缺了本分支给它加的另一半语义——`pixelArt` 会在发给 provider 的提示词末尾追加一行像素风约束。 +- 归因是文档漂移而非遗漏:`docs/openapi/genarrative-external-v1.openapi.json` 的两处 schema **一直是对的**,连具体子句都写了。master 的 `c00dd099e` 把 `api-selection.md` 拆成四篇新参考文档时是从注入之前的版本重写的,于是 skill 文档退回旧语义,而 OpenAPI 保持正确。 +- 我上一轮合并时的核查不到位:只 grep 了「`byte-for-byte` 那个错误说法有没有复活」,确认没有就收工。**验证旧错误的缺席不等于验证新事实的在场**,两者要分别查。 +- 决策:让 skill 文档与 OpenAPI 对齐,措辞不新造。改动限于三行——说明它同时追加提示词子句与启用后处理、`none` 一档不追加也不后处理、`pixelArt` 一档追加一行且是追加而非替换。 +- 刻意不复制子句字面文本:Rust 常量是真值源,OpenAPI 已复制一份,skill 文档再抄第三份就是把同一事实摊到三处——这次漂移正是这么发生的,只是方向相反。文档改为指向 OpenAPI 并写明「本指南刻意不复制」,让下一个读到的人知道那是有意为之而非遗漏。 +- 校验面已确认:这批文档由 `external_skill_api.rs` / `external_mcp.rs` 以 `include_str!` 编译期内联,SHA 在运行时从内容算出、测试只断言「算出的与返回的一致」,没有钉死具体摘要,改文档无需同步任何清单。api-server 700 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致)。 +- 关联文档:`docs/openapi/genarrative-external-v1.openapi.json`。 + +## 2026-08-03 到期清理误删本会话在途占位:补归属登记与前置阶段预算 + +- 缺陷:到期清理只按 `generationStartedAt + 180 秒` 删除 `requiresLiveSession` 且 `generating` 的占位,区分不出它属于已死会话还是本会话仍在执行。占位在创建后还要走源图解析/直传和 `flushProjectPersistence` 才轮到 POST,而 `snapEditorImageToPixelArt` 的 120 秒**只从最终 POST 开始计**。前置阶段慢起来越过窗口时,定时器会删掉本会话正在用的占位并把删除持久化,随后 POST 因占位不存在返回 `409`;若删除的落库晚于 POST 到达,则 completion 找不到占位返回 `Ok(None)`,结果只进素材库、不落画布。 +- 我写在 hook 注释里的安全性论证是错的,两条都错:其一「客户端 120 秒就 abort,180 秒时不可能还是 generating」——120 秒不覆盖前置阶段;其二「不依赖该推理,到期重新判定本身兜得住」——重新判定只能识别**已经收口**的占位,识别不出**仍在合法运行**的占位,后者正处于要被删除的那个状态。第二条错得更本质:它给了自己和读者一道并不存在的第二防线。 +- 前置阶段此前完全无界:直传 `postEditorDirectUploadFile` 是裸 `fetch`、没有 signal,`saveEditorProjectLayout` 的 `requestJson` 没传 `timeoutMs`(同文件其余接口都写了),而 `composeAbortSignal` 在缺失时不设任何默认值。两者各自还有重试(上传最多 3 次尝试、布局保存最多 4 次)。 +- 决策一(归属登记):`activeInlineGenerationDialogIdsRef` 记录本会话仍在执行的占位 id,创建后**紧挨着**注册(中间不能有 await,否则留出「已存在但未登记」的窗口),`finally` 释放;到期清理跳过其中的 id。到期清理本来就只该针对别人留下的孤儿。 +- 决策二(整段预算而非逐请求超时):给「占位创建 → POST 发出」整段 40 秒预算。逐个请求加超时的最坏总时长会因重试累加到远超 180 秒窗口,窗口的前提仍不成立;整段封顶后客户端最坏 40 + 120 = 160 秒,落在窗口内并留 20 秒余量。超时抛裸 `Error` 而非 `ApiClientError`,归入未知结果走对账——上传可能已完成、素材可能已落库,正是对账要处理的情形。 +- 决策三:`saveEditorProjectLayout` 补 `timeoutMs: 60_000`。这是独立缺陷,与本条无关也该修——它被 `flushProjectPersistence` 同步等待在提交路径上,挂住会连带把占位拖过窗口。 +- 「窗口计时起点应改为 POST 发出时刻」未采纳:归属登记之后窗口不再需要覆盖本会话,只用于跨标签页;而对孤儿占位只有创建时刻这一个可用时间戳,改起点无从实现。整段预算已经让窗口的前提重新成立。 +- 验证:新增两条用例——本会话持有期间超窗不清理、释放归属后同一超窗占位立即清理。去掉归属过滤后两条同时变红。`vitest src/components/image-editor` 923 通过 / 74 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 归属登记后的链路复查:一处新引入的忙等、两处无界读写、一处过紧预算 + +- 复查对象是上一条修复本身,找的是新引入的问题,结果三处新增、一处遗留。 +- 新引入(忙等,最严重):归属过滤只加在了 `expiredIds`,没加在 `resolveNextInlineGenerationDialogExpiryAt`。被本会话持有的超窗占位不进 `expiredIds`,却仍被算出一个**已经过去**的到期时刻,`delayMs` 塌成 50ms,定时器触发 → tick → effect 重跑 → 状态没变 → 再挂 50ms,变成每 50 毫秒一次 `setState` 的忙等,持续整个持有期。改为先按归属过滤出 `unownedDialogs`,两处判定共用。 +- 该忙等的可达性不是理论的:前置预算加 POST 之后,catch 里还要做对账 `loadEditorProject`,而归属要到 `finally` 才释放,这段完全可能越过存活窗口。 +- 遗留(无界读取):`loadEditorProject` 同样没传 `timeoutMs`,而 `composeAbortSignal` 在缺失时不设默认值。它正是对账路径上的读取,挂住会让 catch 迟迟不结束,连带把占位拖过窗口——即上一条忙等的直接助推。补 `60_000`。至此该文件里落在完美像素链路上的三个接口(POST、布局保存、项目读取)都有了显式上界。 +- 新引入(预算过紧):上一条把提交前置阶段封顶在 40 秒。该阶段在源图是 inline / 未登记时会真的直传一张画布图层,几 MB 的图在较差移动网络下要几十秒,40 秒会把原本能成功的操作改判为失败——**用「无界」换「过紧」同样是回归**。改为 90 秒,并把存活窗口从 180 秒同步提到 240 秒,维持 90 + 120 = 210 < 240 且留 30 秒余量。 +- 三个常量构成一条跨文件不等式(提交前置预算、客户端 POST 超时、占位存活窗口),任一处被单独调大都会破坏它,后果是跨标签页误删。新增用例把这条不等式连同 30 秒余量一起钉住。本会话自己的占位另有归属登记豁免、不依赖该窗口,所以窗口只需覆盖跨标签页那一侧——这一点也写进了常量注释。 +- 验证:忙等用例用 `vi.getTimerCount()` 直接断言「不该挂定时器」,把 `resolveNext...` 改回未过滤版本后该用例变红。`vitest src/components/image-editor` 924 通过 / 74 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 补齐 editorProjectClient 超时契约的断言 + +- 缺陷:给 `saveEditorProjectLayout` 与 `loadEditorProject` 补超时后,`editorProjectClient.test.ts` 的两条精确参数断言失败。`toHaveBeenCalledWith` 要求参数个数与内容完全匹配,新增第四个参数即不匹配。CI(任务 1645)在 `aa8ea401a` 上报出其中一条,另一条由本地复跑发现。 +- 处置:更新断言把 `{ timeoutMs: 60_000 }` 写进去,而不是放宽成 `expect.anything()`。超时是契约的一部分——这两个接口一个被 `flushProjectPersistence` 同步等待在提交路径上、一个在未知结果对账路径上,没有上界会把在途占位拖过存活窗口。断言写死之后谁删掉它测试就会红;放宽则等于让刚建立的上界失去看守。已验证:去掉生产代码里的两个超时,两条断言同时变红。 +- 真正的问题是验证方式而非测试:改的是 `src/services/image-editor/editorProjectClient.ts`,验证却只跑了 `vitest src/components/image-editor`,改动面与验证面完全对不上。这个盲区在本次会话中期分析另一份 CI 日志时已由我自己指出过,却没有改掉习惯,于是同一个盲区再次漏出——而且这次不是难复现的跨文件竞态,是本地一跑就红的确定性失败。 +- 约定:这条链路横跨 `src/components/image-editor/` 与 `src/services/image-editor/`,往后验证至少同时覆盖两处。不跑全量套件——本机有九条稳定的环境失败(符号链接、`0600` 权限模式、缺客户端 AppData 配置),噪音大于收益。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 对账整段设界,并把提交前置预算变成真正的取消 + +- 缺陷一(对账可被素材库读取永久挂住):对账用 `Promise.all` 同时等项目快照与 `refreshAssetLibrary`,而 `loadEditorAssetLibrary` 至今没有 `timeoutMs`(`composeAbortSignal` 缺失时不设默认值)。`.catch()` 只接住拒绝、接不住永不 settle;catch 体内的 await 不返回,`finally` 就永远不执行——占位归属登记与源图层锁都释放不掉,而到期清理又豁免已登记的占位,页面永久停在 `generating`,同一源图也无法再次操作,刷新前无解。 +- 这个洞是上一条修复留下的:给 `loadEditorProject` 加界时写的注释已经把机制说对了(「挂住会让 catch 迟迟不结束」),却只给同一个 `Promise.all` 里两个 await 中的一个加了界。逐个接口补超时这条路已经漏过一次。 +- 决策一:给**整段对账**设 75 秒上界,而不是继续逐个接口补。往对账里加任何新的 await 都自动受约束。超时必须**解析为 null 而不是拒绝**——这段代码本身位于 catch 内,抛出会穿出整个 async 函数,而调用方是 `void snapSelectedLayerToPerfectPixels(...)`,结果是未处理的 rejection;解析为 null 则落进既有的「权威项目快照读取失败」分支,语义正好一致。 +- 缺陷二(预算只停止等待、不取消):`withPerfectPixelPrePostBudget` 原先只是 `Promise.race`,超时后底层继续跑。直传 `fetch` 没有 signal,被放弃的上传会一路走到 confirm 并注册对象,用户重试再产生一份。 +- 决策二:把同一个 `AbortSignal` 贯穿凭证请求、直传 POST 与 confirm 三步,由前置预算到期时 `abort`。只中止直传会留下未 confirm 的 OSS 对象,只中止 confirm 又会让实体已写入却无记录——要停就整条链一起停。`requestJson` 从 `init.signal` 取信号并与自身超时合成,所以凭证与 confirm 只需在 init 里传入。 +- 未采纳评审建议的全量贯穿(再覆盖重试等待与 flush/save):那要再动两个模块,而收益只是少产生一些用户不可见的存储孤儿;`flushProjectPersistence` 现已有 60 秒上界,最多多挂 60 秒后自行结束。改动面从四个模块降到两个,绝大部分收益保留。 +- 后果分级要说清:缺陷一是永久性的 UI 卡死,缺陷二只是存储层孤儿对象(confirm 注册的是 asset object,不是素材库条目,用户基本不可见)。两者同为 P2 但不同量级。 +- 验证:新增用例让项目快照读取永不 settle,断言 120 秒后完美像素状态回到「空闲」——即 `finally` 确实执行。去掉对账 deadline 后该用例变红。`vitest src/components/image-editor src/services/image-editor` 974 通过,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素第一批:稳定 operation 与单事务数据库提交 + +- 被纠正的旧边界:完美像素原先在 OSS PUT / HEAD 后顺序执行 asset object confirm、project resource、账号素材与 canvas completion 四次独立数据库调用。任一后段失败或本地 timeout/drop 都可能留下可见的部分事实;重试又使用随机 task / object / resource / asset ID,无法把同一次逻辑操作识别为重放。历史 decision 条目保留为当时证据,本条取代其“继续沿用非事务顺序”的现役结论。 +- operation identity:规范化 `canvasCompletion.dialogId` 即 operationId,由 owner + project 共同限定作用域。`taskId = pixel-art-snap-{operationId}`,让响应丢失时的客户端无需服务端回包即可计算;asset object、project resource 与账号素材 ID 用 `SHA256(editor-pixel-art-result-v1 + owner + project + operation + record kind)` 的前 16 bytes 稳定派生。请求 fingerprint 为 64 位 SHA-256,覆盖来源 object key、来源和输出字节摘要、来源资源、素材类型、规范目录 / 标签、canonical generationInputs、canvas completion 与算法版本;最终 OSS object key 必须携带该 fingerprint。 +- 原子边界:OSS PUT / HEAD 仍在事务外。验证上传后,`persist_editor_pixel_art_result_and_return` 受 editor generation runtime service identity 保护,在一次 `try_with_tx` 中校验并写入 asset object、project resource、editor asset,并在同一事务内读取最新 canvas、按当前 revision 完成 dialog。handler 禁止在 procedure 前调用旧 `confirm_asset_object`、resource、asset 或 completion helper。该保证只覆盖这四类结果事实;前置 owner-scoped 项目 / 素材读取仍可能沿用既有 `ensure_default_canvas / ensure_default_asset_folder` 懒建基础记录,不宣称整个 preflight 对数据库零写入。 +- 重放与冲突:三条稳定记录完整且业务内容相同才返回 `AlreadyApplied`,重放不得再次执行 layout CAS 或推进 revision;任一稳定 ID 指向不同内容、同 object location 被其他 ID 占用、同 operation 输入漂移或三条记录只有部分存在都失败关闭并映射 HTTP `409`。时间字段不参与 exact replay 内容比较。权威 dialog 已删除时三条记录同事务提交、canvas / revision 不变并返回 `DialogMissing`。 +- 未知结果语义:本地 procedure future 的 timeout/drop 不能撤销远端事务,所以首个 PUT 后继续设置 `resultPersistenceStarted=true`;该标记现在表示“OSS 或整笔数据库事务的结果未知”,不再表示数据库可能部分提交。事务失败后允许留下无引用 OSS object,本批不做破坏性删除或历史孤儿清理。 +- 明确延期:本批只交付后端原子性与可重放身份。前端仍需后续批次持久化 operation 请求快照、让素材刷新退出 verdict、轮询项目事实、引入 `pending-confirmation`、刷新后只恢复 GET,并让人工重试复用原 operation;在此之前不能宣称 unknown-result 已端到端闭环。 +- 2026-08-03 第二批边界:generation dialog 持久化版本化 `perfectPixelOperation`,绑定规范化 dialog/operation、固定 `pixel-art-snap-{operationId}` task、稳定来源解析后的完整 POST 请求以及 `submittedAt / reconcileUntil` 整链绝对窗口。只有布局 PATCH 已确认包含该快照才允许首次 POST;素材刷新退出 verdict。响应未知后按稳定 task resource 与 dialog/layer 的原子事务形状有界轮询项目 GET,未终态或读取到期统一保持 `pending-confirmation`,不标普通失败、不自动重放。显式人工重试必须 byte-for-byte 复用持久请求和同一 identity,当前 UI、来源、目录、类型或标题变化不得改变请求;无效快照失败关闭。首次提交或重试在途时 owner、project 或组件生命周期改变后,旧响应的素材、项目、提示和对账副作用全部忽略。 +- 2026-08-03 前端 hydrate 收口边界:hydrate 后对有效 `generating` / `pending-confirmation` operation 只做 GET-only 恢复,禁止自动 POST、上传或重建请求;切换 owner/project、卸载或权威 revision 前进时取消旧观察。新写入的 v1 快照固定使用 75 秒跨度;读取侧兼容第一批曾写入的 240 秒 v1 形状以保留 operation identity。跨设备时钟让 `submittedAt` 落在可接受的未来区间时,先把它规范化到当前时间,再把实际截止压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;这样既不借兼容延长观察,也不会写出 `reconcileUntil < submittedAt` 的二次 hydrate 无效形状。有效 durable operation 退出 legacy `requiresLiveSession` TTL,任何标签页都不得清理;无 operation journal 字段的历史 inline 孤儿继续按 TTL 兼容,且剥离时同步顶层与 `canvas.layers` 两份布局。轮询耗尽仍保留 operation 和待确认状态,只有显式重试进入第二批 exact replay。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 完美像素第二批:object-only 上传与 GET-only unknown 收口 + +- 上传边界:inline 源图不再调用会在 confirm 后继续换签的完整上传 helper,只执行 `ticket → OSS PUT → confirm → objectKey`。完美像素在创建占位后立即确定 dialog / operation ID,并把它作为稳定 upload ID;源 fetch、图片解析边界、ticket、PUT、confirm 共用前置预算的同一个 `AbortSignal`。完整 helper 的 signed URL 调用也防御性透传 signal。这样 confirm 成功后没有新的换签失败窗口,同一 operation 的内部重试也不会换对象路径。 +- verdict 边界:POST 成功不再直接采用响应体的 `project`,POST 中的 `asset` 也只有在项目 GET 已确认终态且 response task / resource 与 GET resource 一致时才允许本地 upsert。项目 GET 是唯一 verdict 来源;匹配 task resource + 已收口 dialog/layer 为画布成功,无 dialog + 匹配 task resource 为 asset-only,resource 已出现但 dialog 仍 generating 继续等待,无 dialog 且无匹配 resource 也继续等待。重复匹配 resource 或已收口 dialog 与 resource / layer 不一致失败关闭为 conflict,不猜测成功。 +- 时间边界:`submittedAt / reconcileUntil` 从稳定请求快照写入时形成单个 75 秒整链绝对窗口;POST 正常回包或异常都不能替同一次 operation 续期,只有用户显式 exact replay 才开启新的 75 秒窗口。每轮先立即 GET,一次读取即使发现窗口已过期也必须执行;随后退避上限 5 秒。读取始终失败或窗口耗尽时保持 `pending-confirmation`,不声称素材已保存。滚动升级时兼容读取旧 240 秒 v1 journal;hydrate 会先把可接受的未来 `submittedAt` 规范化到当前时间,再把截止收紧到规范化提交时间和当前时间各自允许的 75 秒上限,并在下一次布局持久化时写回仍可再次 hydrate 的收紧形状。 +- identity 与删除:unknown 保留原 dialog 上的完整 `perfectPixelOperation`,人工重试原样发送持久化 request;普通按 ID 删除和随源图层删除均保留未收口 durable operation。对话框删除入口会激活原占位并提示继续核对 / 原样重试;Delete 快捷键若只命中受保护 operation 则在写历史、清选择或执行副作用前完整 no-op,混合选择只统计并删除其它可删除目标。刷新恢复只做 GET,owner / project 切换或卸载会取消旧观察。完全没有 operation journal 字段的 legacy inline 占位仍沿用既有 TTL;字段存在但损坏时保留失败关闭标记,不能降级成可清理的旧占位。 +- 投影刷新:`refreshAssetLibrary` 只在项目终态后 best-effort 触发,并同时吞掉同步 throw 与异步 reject;永不 settle 的刷新 Promise 也不参与 await,因此不能阻塞项目应用、提示或 `finally` 解锁。 +- 验证:第二批定向覆盖 POST 成功后仍走 GET、unknown 的 pending → completed、no-dialog 正反证据、75 秒绝对截止与 5 秒退避、过期后至少一次 GET、stable upload ID、object-only 上传、整条 signal、刷新永挂 / 同步抛错 / 异步拒绝、删除保护、hydrate GET-only 与 byte-for-byte replay。Atomic 全局相对断言及其它文档清理在本次第二批提交时尚未纳入,后续由下一条第三批完成。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 完美像素第三批:移除全局 Atomic 相对断言并完成文档收口 + +- 竞态根因:过期预算用例先读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH`,再在断言前读取一次;相邻 Drop 用例可在两次 load 之间创建或释放 guard。相对 before/after 与绝对 `== 0` 一样没有跨测试隔离,Rust 默认并行时仍会随机失败。 +- 测试边界:`pixel_art_snap_permit_reports_exhausted_budget_without_waiting` 只构造过期 deadline 并断言 `504`,不再观察全局队列深度。`pixel_art_snap_queue_depth_returns_to_zero_after_guards_drop` 继续作为独立 Drop 契约用例;未引入 `--test-threads=1`、全局串行锁或其它掩盖手段。 +- 文档收口:后端数据契约、连接池 Drop 说明、图片画布方案、decision log 与 pitfalls 同步撤回“相对断言可消除并行竞态”的错误保证。连接池 lease 的 Drop 只保证本地 slot / permit 可回收,不表示 handler timeout/drop 能取消或回滚已经发出的远端 procedure。 +- 验证结果:`cargo test --manifest-path server-rs/Cargo.toml -p api-server pixel_art_snap` 为 17 通过 / 0 失败;`npm run check:rustfmt`、`npm run check:encoding`(5153 个文件)与 `git diff --check` 通过。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 inline 占位 ownership 释放改为可观察信号 + +- 覆盖缺口:2026-08-03 的“释放归属后同一超窗占位立即清理”测试在 `rerender` 时同时创建了新的 dialogs 数组和 callback,effect 实际由这些依赖变化唤醒;它没有证明 `finally` 里单独执行 `Set.delete()` 会重新判定。生产实现把稳定 ref 对象放进依赖,但 React 不观察 `.current` 内容变化,因此旧测试与旧实现之间存在同一个盲区。 +- 决策:Set 继续作为首个 await 前同步可见的 ownership 真值,但封装进 `useInlineGenerationPlaceholderOwnership`,不再向 View 暴露可变 ref。`claim / release` 只有在 membership 真变化时才推进 version;到期 effect 同时依赖稳定 `has` 和 version。首次提交与人工 exact replay 通过同一份 ownership 登记 / 释放,hydrate GET-only 恢复只查询这份 ownership 来避开本页 live Promise,不能在 View 创建第二份 registry。 +- 时序边界:`claim` 仍紧挨占位创建且早于任何 await;`release` 仍位于 `finally`。version 只负责 React 通知,不替代同步 Set,也不清理 observed recovery key;否则可能在 live Promise 尚未退出时启动第二条 GET。重复 claim / release 为幂等 no-op,不额外触发 effect。 +- 验证:hook 定向测试使用生产 ownership hook、固定 dialogs 数组和固定 callbacks,并包在 StrictMode 中;单次 render 后先 claim 取消到期 timer,推进到超窗仍不清理,再仅 release 唤醒 effect并清理一次。重复 claim / release 分别保持 version `1 / 2`,删除与通知均只发生一次。hook 定向测试 10 条、generation workflow 定向测试 81 条通过;`npm run typecheck`、全仓 `npm run lint:eslint`、`npm run check:encoding` 与 `git diff --check` 通过。未追加其它测试或全量测试套件。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 项目快照对账不再按重复 dialog ID 首项短路 + +- 缺陷:项目快照查询 helper 只返回第一个同 ID generation dialog。legacy / External API 画布若含重复 ID,首条已收口、后条仍 unresolved 时,通用 queued completion 会漏掉用于收口的第二次 GET;完美像素还可能把不满足后端唯一性契约的快照误判为成功。 +- 决策:快照 helper 返回全部同 ID 原始记录。通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不按其中任意一条猜测结果。无需改变 hydrate、删除、后端或 OpenAPI。 +- 验证:两条 `duplicate` 定向用例通过;`npm run typecheck`、改动文件级 ESLint 与 `npm run check:encoding` 通过,未运行目录或全仓测试套件。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 完美像素对账读取改用请求全生命周期绝对截止 + +- 缺陷:`loadEditorProject` 的 `timeoutMs` 只包住业务 `fetch` 等响应头。缺 token 时的登录恢复发生在该 timer 建立前,401 后的共享 refresh 等待也不受它限制;收到响应头后 timer 已清理,成功和错误响应的 `response.text()` 又可继续永久挂起。任一环节不 settle,完美像素对账都无法重新检查 75 秒窗口,首次提交的 dialog ownership 与图层锁也无法进入 `finally` 释放。 +- 决策:`requestJson` 新增 opt-in `deadlineAt`,从函数入口建立一次 lifecycle signal,覆盖缺 token 补票、业务 fetch、401 refresh 等待、所有 GET attempt、退避和成功 / 错误响应体读取。等待共享 refresh 只取消当前调用者,不把该 signal 传入共享 refresh 请求,避免一次图片对账超时取消 AuthGate 或其它请求正在复用的刷新;signal 到期也不得被鉴权 catch 吞掉后继续发业务请求、清 token 或广播登录态变化。未传 `deadlineAt` 的调用保持既有 `timeoutMs` 行为。 +- 对账边界:窗口内每次 GET 的 deadline 为 `min(reconcileUntil, readStartedAt + 10 秒)`;operation 已过期但从未读取时仍执行一次即时 GET,该例外最多 10 秒。deadline 只把本次读取视为失败,不穿透成未处理 rejection;轮询随后返回 `pending-confirmation` 并让首次提交 / hydrate 恢复的既有清理链执行。 +- 验证范围:`apiClient` 定向覆盖缺 token 与 401 refresh 永久等待、响应体永久等待;`editorProjectClient` 钉住 deadline 透传且普通读取仍保留 60 秒默认 timeout;generation workflow 钉住窗口内和过期单次读取的 deadline。未扩大为全站请求超时迁移。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 完美像素 confirm 后 strict 保存失败保留原 operation + +- 缺陷:源图 `ticket → PUT → confirm` 成功后,首次 strict layout flush 仍沿用从上传开始计算的 90 秒预算。预算耗尽会把占位标成普通 `failed` 并解锁;既有重试只接受 `pending-confirmation`,普通按钮遂创建新 dialog、重新上传并换 operation identity。旧 confirmed source object 无法再由用户路径引用。 +- 决策:源准备 90 秒与 operation journal strict save 60 秒拆开。confirm 后立即形成稳定 `perfectPixelOperation`,strict deadline 覆盖等待活动保存、PATCH、revision conflict reload 和 transport retry;到期会取消 strict request/waiter、阻止自动转移或继续 POST,并释放本地活动保存槽。浏览器 abort 不等于远端撤销,迟到 PATCH 仍可能提交,但其本地 Promise 不再触发 POST;后续重试依靠 revision CAS/reload 收口。 +- 状态与重试:POST 尚未发出时的 strict 失败保留 `failed + perfectPixelOperation`,不做结果 GET;原占位提供 exact retry,复用同一 request、source objectKey、dialog/operation/task identity,不重新执行 ticket、PUT 或 confirm。普通完美像素入口把该状态视为未收口,不能创建第二条 operation;普通删除和随源图删除同样保留 identity。 +- 预算不变式:源准备 90 秒加 strict journal 60 秒仍小于 legacy inline 占位 240 秒窗口;POST 只会在 operation journal ACK 后开始,因此不再计入该 legacy TTL。布局保存本身使用请求全生命周期绝对 deadline,覆盖鉴权等待、业务 fetch 与响应体读取。 +- 剩余边界:confirm 成功后浏览器立即崩溃、且 operation 首次 PATCH 尚未落库时,仍可能留下 object-only 记录。这里的 object-only 是指 OSS 中已有私有源图文件、数据库也已有对应 `asset_object / objectKey` 登记,但尚无 `perfectPixelOperation` journal、项目 resource、素材库 asset、完美像素结果或画布结果图层。用户界面不可见且刷新后无法复用该 identity,再次点击可能重新上传;影响限于不可达的源图存储与垃圾记录累积,不代表结果已生成、重复扣费、越权或数据泄露。 +- 本 PR 的修复边界到此为止:只保证 operation 已形成后,strict 保存失败或超时不会丢失 identity、不会重新上传,并且迟到 PATCH 不会继续触发 POST;不继续引入服务端 durable upload journal、上传 reservation、孤儿对象扫描/回收或历史数据清理,也不宣称撤销已发送的 PATCH。彻底消除上述崩溃窗口需要独立设计、评审和交付,不作为本 PR 的合并阻断项。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 generation 占位右键删除复用统一请求保护 + +- 缺陷:快捷键删除已在写历史前过滤未收口完美像素 operation,但 generation 占位的右键菜单仍直接调用低层 `removeCanvasGenerationDialogById`。低层会保留受保护 operation,上层却已写入 `delete-generation-result` 历史、清空选择并关闭交互,形成“占位未删但出现伪历史和 UI 副作用”的不一致;普通 generating dialog 也会绕过既有删除确认。 +- 决策:纯 generation-dialog 的右键删除在任何历史或选择副作用前委托给 `requestRemoveCanvasGenerationDialog`。未收口完美像素只激活原占位并显示继续对账/原样重试提示;普通 generating 进入现有确认弹窗;终态占位才执行真实删除。层命令保留低层回调给快捷键和混合选择的既有可删除目标,不扩大本次改动为删除系统重构。 +- 验证:层命令定向测试构造 `pending-confirmation + perfectPixelOperation` 右键目标,断言请求保护入口只调用一次、历史为零、选择保持、低层删除未调用且菜单收口。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 完美像素最终 PNG 在 PUT 前执行只读 preflight + +- 缺陷:最终 PNG object key 虽已稳定,目录、画布 completion 和布局大小门禁仍只在 OSS PUT / HEAD 之后的原子 procedure 内判定。可预知的自定义目录缺失、目录越权、重复 dialog 或 2 MiB / 512 KiB 布局拒绝会先产生无引用 OSS object,再返回确定失败。 +- 决策:上传 helper 拆成纯 prepare 与 execute。prepare 只生成精确 object key / request,不访问 OSS;handler 用同一 object key 构造候选 project resource,调用受 runtime service identity 保护的只读 `preflight_editor_pixel_art_result_and_return`。preflight 允许尚未创建的默认目录,要求自定义目录存在且属于 owner,复用 `plan_editor_pixel_art_canvas_completion`,并对 legacy / structured 结果布局执行 2 MiB 总量与 512 KiB 单项门禁。通过后才执行 PUT / HEAD,再调用既有原子 persist。 +- 预算与 unknown 边界:preflight、PUT / HEAD 和最终 persist 共用既有 60 秒绝对 deadline。preflight 失败或超时发生在第一次 PUT 之前,不带 `resultPersistenceStarted`;从第一次 PUT 发出开始继续沿用 unknown 标记和项目 GET 对账。 +- 权威性与剩余风险:preflight 不创建锁、reservation 或新表记录;最终 `persist_editor_pixel_art_result_and_return` 仍在同一事务内重复目录、布局、幂等 identity 和 revision 校验。preflight 通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;彻底消除该 TOCTOU 需要 durable reservation / journal 或事务协调,不在本 PR 的最小修复边界内。 +- 契约影响:只新增 SpacetimeDB procedure ABI 与生成 bindings;没有表字段、index、migration、HTTP DTO、路由、状态码、OpenAPI 或 shared-contracts 变化。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 ## 2026-08-04 AI 游戏项目 manifest 存储与工作台实时投影 - 存储决策:`.agent/manifest.json` 的版本追加不可变约束由同目录持久专用锁保护,读取旧状态、校验版本前缀、安装临时文件和安装后回读必须处于同一临界区;进程内 Mutex 不能替代跨进程文件锁。 @@ -6114,3 +6437,140 @@ - telemetry 只扫描可见 DOM 文本和 AST 可达的 JavaScript:hidden DOM、字符串/注释、恒假分支、未调用函数和 inert/raw-text 内容不得补齐状态字段;已链接 classic/module 单元沿同一可达扫描口径判定。玩法 identity 保留独立的现有识别口径,不能反向补齐 telemetry。 - CSS `url(...)` 的资产路径保持原始大小写解析,stylesheet 证据必须同时命中实际可见元素;未命中 selector、元素自身或祖先 hidden、以及匹配隐藏规则的节点均不作证。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-05 画布图层元数据以资源行为准,读边界补齐 sourceType + +- 背景:结构化画布保存要求图层布局项里的资源权威字段与 `editor_project_resource` 行逐字相等,否则整次 PATCH 报「与项目资源不一致」,而该 400 属于 non-retryable,会被前端保存队列静默吞掉。但读边界并不把这些值原样下发:`sanitize_editor_user_model` 会脱敏内部处理模型、`provider` 被无条件省略(见 2026-07-31 修正抠图内部元数据的普通用户读取边界),`sourceType` 则在结构化保存校验通过后被归还资源行、图层列置空,读回时整个键不存在。客户端拿不到权威值只能自己补——`resolveHydratedLayerModel` 沿来源链推导出展示用生图模型,`hydrateLayer` 把缺失的 `sourceType` 猜成 `uploaded`——再原样回写,判等于是必然失败。前者命中含 2026-07-30 之前抠图派生资源的画布,后者命中所有 generated 图层;两者都在项目重新加载后的首次保存触发,用户侧表现为「改动悄悄没保存」,完美像素因为提交前是严格保存才把服务端原文暴露出来。 +- 决策:被读边界脱敏或不下发的字段,一律以资源行为准,客户端不参与回写。`serializeLayer` 对**挂着项目资源行**的图层(`resourcePersistenceState === 'registered'`)不再输出 `model` / `provider`;缺资源行的自包含 legacy 本地图片序列(角色动画逐帧层等)必须继续输出——服务端 `normalize_structured_canvas_layer_against_resource` 对 `resource == None` 走早退分支,只摘 `assetKind` 就把 item 原样写回,`item_json` 是这类图层元数据的唯一存储,停发会让模型信息在下一次保存后永久丢失。`normalize_structured_canvas_layer_against_resource` 对这两个字段改为直接丢弃而不判等——它们属于纯丢弃字段,判等通过与否都不写回资源行(区别于会合并回资源的 `assetKind` / `generationInputs`),放宽不影响任何持久化状态。`sourceType` 属于意外丢失而非有意脱敏,改为在读边界按图层自己声明的 `resourceId` 回填权威值,口径与既有 `objectKey` / `assetObjectId` 一致;客户端 `hydrateLayer` 同时把缺键回落到资源值作为兜底,不再猜 `uploaded`。 +- 不变式:凡是 owner 读边界会脱敏或省略的图层字段,写边界不得对其判等;凡是写边界要判等的图层字段,读边界必须原样下发或可由资源行回填。改动任一侧时必须同时检查另一侧,只改一侧即构成本条缺陷的复发。 +- 影响范围:`src/components/image-editor/ImageCanvasEditorModel.ts` 的 `serializeLayer` 与 `hydrateLayer`、`server-rs/crates/api-server/src/editor_project.rs` 的 `EditorPayloadMediaReference` 与 `sanitize_editor_payload_media_value`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs` 的 `normalize_structured_canvas_layer_against_resource`。不修改 SpacetimeDB schema、迁移或绑定,不改动历史数据,不改变对外契约。 +- 遗留:历史资源行的 `model` 列仍存有 2026-07-30 之前写入的内部处理模型,读边界继续脱敏它。把该列回填为源生图模型、原值移入 `generationInputs.mattingModel`,并据此删掉两侧的脱敏与推导逻辑,另行排期,不在本次范围。 +- 验证方式:前端覆盖已登记资源的图层产物不含 `model` / `provider`、自包含本地序列仍保留并可往返,以及「序列化后去掉 sourceType → hydrate → 再序列化」仍为 `generated` 的往返不变式;api-server 覆盖读边界按 `resourceId` 回填 `sourceType`、且缺资源行的 legacy 本地序列保持自带值;spacetime-module 覆盖资源行存内部处理模型而图层带推导值时不再报错、读回时 `sourceType` 键确实被丢弃、以及显式冲突的 `sourceType` 仍失败关闭。运行 `npx vitest run src/components/image-editor`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_project::`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml --all-targets`、`npm run typecheck`、`npm run check:encoding`、`npm run check:rustfmt`。spacetime-module 的单测二进制在 Windows 本机链接失败(缺 SpacetimeDB 宿主符号),本机只能做到 `cargo check --all-targets`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 完美像素占位恢复为可删除,删除保护条款作废 + +- 背景:2026-08-04「完美像素第二批:object-only 上传与 GET-only unknown 收口」把「未收口的完美像素 operation」定为不可删除,理由是结果 unknown 时要保留 identity 供原样重试;同日「confirm 后 strict 保存失败保留原 operation」又把 POST 尚未发出的 `failed` 一并纳入,理由是从源图重开会重新执行 ticket / PUT / confirm,让上一次已 confirm 的源图对象失去引用。两条叠加后,右键删除、Delete 快捷键、随源图层删除、多选删除全部豁免该占位,到期清理也不覆盖它,用户画布上出现了删不掉的元素。 +- 缺陷判定:删除占位**不撤销任何在途请求**——完美像素没有取消接口,结果照常落库并进素材库;服务端 completion 发现 dialog 已不在会返回 `DialogMissing`,客户端本就有对应提示。封锁买到的只是「结果自动回填画布」这一便利,代价却是用户文档不可编辑。至于孤儿源图对象,「confirm 后 strict 保存失败保留原 operation」自己的「剩余边界」一节已把同类残留定性为「影响限于不可达的源图存储与垃圾记录累积……不作为本 PR 的合并阻断项」;为避免同一种残留而禁止用户删除自己画布上的元素,权衡不自洽。 +- 决策:本条取代上述两条中关于**删除**的条款。完美像素占位在 `generating` / `pending-confirmation` / `failed` 任何状态都可删,且不弹确认。低层 `removeCanvasGenerationDialogById` 恢复为无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因;随源图层删除不再豁免;Delete 快捷键与多选删除不再过滤该目标。删除确认的判据收敛为具名的 `requiresGenerationDeleteConfirmation`:现成弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立,而完美像素 `generation_cost_mud_points = 0`。运行时标记 `perfectPixelOperationInvalid` 时必须同时丢弃 `perfectPixelOperation`,与 `hydrateCanvasGenerationDialog` 口径一致,不再留下「重试按钮因 invalid 消失、快照却还挂着」的矛盾态。 +- 保留不变:保留 operation identity 供「在原占位原样重试」的能力不变,重试仍复用同一 request、source objectKey 与 dialog / operation / task identity。从源图重新发起仍被 `existingOperation` 闸拦住,另行处理。带 operation 的占位继续豁免 inline 占位 TTL 到期清理——用户主动删除与系统替用户删除是两回事。 +- 已知后果:删掉 `pending-confirmation` 占位后,刷新恢复不再对账这条 operation;结果若已生成只会出现在素材库,不回填画布。这是用户主动放弃的结果,不是回归,不得据此判定为缺陷。 +- 影响范围:`src/components/image-editor/useCanvasGenerationDialogs.ts`(删除 `isUnsettledPerfectPixelOperationDialog`,新增 `requiresGenerationDeleteConfirmation`)、`ImageCanvasEditorView.tsx` 的 `requestRemoveCanvasGenerationDialog`、`useImageCanvasLayerCommands.ts` 的 `deleteSelectedLayer`、`useImageCanvasGenerationWorkflow.ts` 的恢复失效分支。不修改服务端、契约或数据。 +- 验证方式:覆盖三种状态下按 id 删除与随源图层删除均真正移除、完美像素占位任何状态都不要求确认而普通 `generating` 占位仍要求、快捷键与混合选择删除会写入历史并触发副作用、在途删除后已知失败退回全局提示、删除后 applied verdict 走 asset-only 提示且不回填画布、标记失效时快照被丢弃。运行 `npx vitest run src/components/image-editor`、`npx vitest run src/components/platform-entry`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 完美像素请求账本移出项目布局,严格布局保存整体删除 + +- 背景:完美像素是唯一没有 durable job 的生成路径——免费、同步、不走 `enqueue_editor_generation_job`,服务端没有任何一行记录「这次请求发出过」。为了让刷新后还能 GET-only 对账,请求账本 `perfectPixelOperation` 被写进了**用户的画布布局**,并由此派生出一条严格布局保存通道:发 POST 前必须拿到布局保存的 revision ack,否则整条链路中止。该耦合直接造成两类缺陷:一是账本寄生在用户数据上,占位一度被禁止删除(已由同日「完美像素占位恢复为可删除」作废);二是任何布局校验失败都会升级成完美像素的硬阻断,「画布图层元数据以资源行为准」那条缺陷正是因为严格保存才从静默重试变成用户可见的死锁。 +- 决策:账本改由 `src/components/image-editor/perfectPixelOperationStore.ts` 存在本机 localStorage,按 owner + project 双键隔离;布局里只留 `perfectPixelOperationId` 标记,用来把这类占位与队列型占位区分开。发 POST 前先同步写本机账本,再**尽力而为**地保存布局;布局保存失败不再让客户端拒发 POST。(**更正**:本条原文写作「布局保存失败不再拦 POST,只是把结果降级为『只进素材库』」,不准确——服务端要求占位此前已持久化,占位从未落库时 POST 会被 409 拒收。被解除的是「客户端在拿到 revision ack 前拒发」这一**客户端侧**阻断,不是端到端依赖;见 2026-08-05「pre-POST flush 是服务端硬前置」。)严格布局保存通道(`strictCompletion` 全套机制、`flushProjectPersistence` 的 `requireSuccess` / `requiredDialogId` / `deadlineAt` 选项、`PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS`)整体删除,只保留一个不改变失败语义的 `preferLatestGenerationDialogs`,用于取到刚创建、尚未回流到 ref 的占位。 +- 为什么这样仍然安全:本机写入是同步的、不过网络、不受服务端校验影响,因此它能提供严格布局保存想提供的那个保证——「请求可被追溯」——却不引入任何阻断点。由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清账本。 +- **本机账本是明确设计,缺失只降级、不得构成阻断**:换设备、换浏览器、清缓存、隐私模式、配额写满都会读不到账本。这些情况下带标记的**未收口**占位一律 hydrate 成 `failed + perfectPixelOperationInvalid` 的**可删除**占位,用户删掉重来即可(收口态占位不适用,见下一条决策)。任何代码路径都不得因为「读不到账本」而拒绝用户发起、重试或删除,也不得把占位停在无从收口的处理中态。跨设备不再自动收口是本决策已知且接受的代价:完美像素免费且同步,重做成本极低,用它换掉「用户数据里混着系统对账状态」的耦合是划算的。后续复查发现该行为时,应对照本条确认为设计,不得判定为回归。 +- 兼容与生命周期:布局内联账本是 legacy 形状,hydrate 侧继续认,滚动部署期间的在途操作不会被一次性判死;写入侧不再产生新的内联账本。本机账本按 7 天保留期与 32 条上限裁剪,终态(applied / dialog-missing / 快照与项目不匹配 / 无占位可挂错误)立即清除。读取沿用与布局快照相同的 v1 白名单校验,任何字段漂移失败关闭,绝不据一份可疑账本重放 POST。 +- 影响范围:新增 `perfectPixelOperationStore.ts`;`ImageCanvasEditorTypes.ts` 新增 `perfectPixelOperationId`;`ImageCanvasEditorModel.ts` 的 `serializeDialogReferences` / `hydrateCanvasGenerationDialog` / `splitCanvasLayoutItems` / `dropDeadInlineGenerationPlaceholders`;`useImageCanvasProjectPersistence.ts` 删除严格保存机制并在 hydrate 时读账本;`useImageCanvasGenerationWorkflow.ts` 的提交、重试与恢复 effect;`useImageCanvasGenerationSurface.tsx` 的 props 类型。不修改服务端、SpacetimeDB schema 或对外契约——服务端从来不认识这个字段。 +- 验证方式:账本单测覆盖往返、owner / project 隔离、跨账号整条丢弃、被篡改条目失败关闭、保留期与条数裁剪、终态清除、以及存储不可用时静默降级;模型层覆盖「布局只留标记且不含源图地址」「标记在而账本缺失时收口为可删除失败态」「账本 id 与占位不符时失败关闭」;工作流覆盖「布局保存失败仍照发 POST 并保留可重试的 operation」与「孤儿账本条目照常对账并在终态清账本」;持久化层覆盖「布局保存 400 / 403 与缺 authority 时 flush 均不抛、下游照常执行」。运行 `npx vitest run src/components/image-editor`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 账本寿命短于标记寿命:收口态不需要账本,孤儿账本不看对账窗口 + +- 背景:上一条把请求账本移到本机后引入了一条判据——「布局里有 `perfectPixelOperationId` 标记、本机没有账本 ⇒ 该占位无效」。这条判据按构造就是错的,因为两者寿命根本不对称:标记写进布局后**寿命无限**(服务端完成 completion 时只做字段级改写,置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清 `errorMessage`,从不摘掉标记,见 `editor_project_storage.rs` 的 `plan_editor_pixel_art_canvas_layout`),而账本**寿命很短**(收口即清、75 秒对账窗口、7 天保留期、换设备即无)。账本消失是正常终态,不是异常。同一个不对称还以第二种形态出现在恢复 effect 里:孤儿账本按 `reconcileUntil` 短路。 +- 缺陷一(每一次成功都被判成失败):`settleLivePerfectPixelVerdict` 在 applied 终态先清账本,紧接着 `applyProjectSnapshot` 用真实 hydrate 重新套用权威快照;此时布局里标记还在、账本已清,于是成功结果被判成 `failed + perfectPixelOperationInvalid`,用户点开刚生成的图层会看到「操作快照无效」。更糟的是这个状态会被下一次自动保存序列化回服务端,覆盖服务端正确的 `idle`;`perfectPixelOperationInvalid` 一旦落库,此后单凭它就能强制 `failed`,**自我固化**。历史上早已完成的完美像素占位(当时带内联账本)在标记化改写后同样中招。 +- 缺陷二(兜底分支在唯一目标场景下失效):孤儿账本对账是「删掉严格布局保存仍然安全」的全部依据,目标场景是「POST 已发、布局没落盘、浏览器关闭、稍后重开」——而重开几乎必然晚于 75 秒对账窗口,`operation.reconcileUntil <= now` 的短路让这条分支基本永不生效,条目还会在本机躺满整个保留期反复被跳过。 +- 决策一:凡是「缺账本 ⇒ 无效」的判据,一律先排除**收口态**。收口的定义复用既有的 `isUnresolvedCanvasGenerationDialogRecord` 取反:带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`。收口态占位不需要账本——`generatedLayerId` 本身就是服务端已回填的证据;它同时**无条件忽略**已落库的 `perfectPixelOperationInvalid` 标记与残留 `errorMessage`,并把状态强制归位到 `idle`,让被上一版写脏的行在下一次 hydrate 时自愈。写边界同步收窄:`serializeDialogReferences` 对收口态占位不再输出 `perfectPixelOperationId`,让标记的寿命与账本对齐,不再在布局里堆积。 +- 决策二:孤儿账本**不按 `reconcileUntil` 短路**。`reconcileUntil` 的语义是「结果可能还在飞,值得多读几次」,而孤儿是上一个会话留下的、发出它的标签页早已不在,需要的是一次能回答「到底落没落」的确定性读,不是轮询。为此新增 `readPerfectPixelOrphanVerdict`(单次 `loadEditorProject` + `inspectPerfectPixelProjectSnapshot`),不复用 `reconcilePerfectPixelProject` 的轮询循环——后者在窗口耗尽时确实会因 `hasAttemptedRead` 初值为 false 而强制读一次,但那是实现副作用而非契约,寄生在上面迟早被重构静默破坏。收口口径:`dialog-missing` → 刷新素材库 + asset-only 提示(这正是布局尽力保存失败的典型结局);`applied` → 静默刷新素材库(本次读到的就是当前项目的权威状态,结果本就在用户眼前,弹提示只是噪音);`pending` / `conflict` → 完全静默(什么都没落库,用户无需知道)。三者都清账本;**读失败不清**——那是「不知道」而非「知道没有」,留给下次加载。 +- 保留不变:未收口占位在账本缺失时仍然收口成可删除的失败态,上一条决策的「缺失只降级、不得构成阻断」原样有效。本条只是把「缺失」的适用范围限定在它本来就该管的那一半。 +- 同类判据的通用要求:本仓库中任何「持久化标记 + 短寿命本地状态」的组合,判据都必须先问「这个标记所指的事情是不是已经结束了」。只要标记比它依赖的状态活得久,`marker && !state ⇒ invalid` 就一定会把正常终态误判成异常。 +- 影响范围:`ImageCanvasEditorModel.ts` 新增 `isSettledPerfectPixelDialogRecord` 并改写 `serializeDialogReferences` / `hydrateCanvasGenerationDialog`;`useImageCanvasGenerationWorkflow.ts` 新增 `readPerfectPixelOrphanVerdict` 并改写恢复 effect 的孤儿分支。不修改服务端、SpacetimeDB schema 或对外契约。 +- 测试缺口的根因与补救:缺陷一能溜过整套测试,是因为工作流用例里所有 applied 场景的 `applyProjectSnapshot` 都是空桩,「收口 → 清账本 → 真实 hydrate 重新套用」这条**跨 hook 协作**从未被跑过;模型层用例又只覆盖了 `generating` + 账本缺失,没有 `idle + generatedLayerId` 这一真实终态形状。补救不是多加两条断言,而是新增一条把 `verdict.project` 真正喂进 `splitCanvasLayoutItems` 的集成用例,并把共享 fixture `createPerfectPixelProject` 补上服务端真实会保留的 `perfectPixelOperationId`——fixture 不还原真实形状,下游所有用例都在测一个不存在的世界。 +- 验证方式:模型层覆盖「收口态无账本仍有效且序列化不再输出标记」「被上一版写脏的行自愈成 idle 且清掉残留错误文案」「收口态的内联 legacy 账本被剥离且不留标记」;工作流层覆盖「applied 结果经真实 hydrate 回来仍是 idle」(该用例已实证:回退修复后报 `expected 'failed' to be 'idle'`)、「过期孤儿仍做且只做一次读并清账本」、「未落库的孤儿静默清账本、不提示、不刷新素材库」。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 pre-POST flush 是服务端硬前置,对账窗口改在 flush 之后锚定 + +- 事实更正:`server-rs/crates/api-server/src/editor_project.rs` 的 `validate_editor_pixel_art_snap_placeholder_exists` 在处理前检查占位是否**已经持久化**到项目布局;既没有同 ID 的已持久化 dialog、也没有同 operation 的稳定 resource 时返回 **409**。因此 POST 前那次 `await flushProjectPersistence` 不是可省的画布同步,而是服务端硬前置,不能简单取消。上一条决策里「布局保存失败不再拦 POST,只是把结果降级为『只进素材库』」的说法就此更正:准确表述是——**占位从未持久化时服务端返回 409;best-effort flush 不再提供成功 ACK,因此客户端无法证明该前置条件已经满足,只能提高满足它的概率**(占位可能已被此前的 450ms 自动保存落库,PATCH 也可能成功而 ACK 丢失)。被解除的是客户端侧「拿不到 revision ack 就拒发」的阻断,不是端到端依赖。 +- 缺陷:首次提交与人工 exact retry 都在这次 flush **之前**就算好 `submittedAt / reconcileUntil`。该 flush 没有整体上限(单次 PATCH 60 秒 × 最多 4 次尝试,且 flush 的等待循环会清掉退避定时器立刻重跑),慢保存足以在 POST 发出前烧光整个 75 秒窗口,请求带着已过期的 reconciliation deadline 发出,对账退化成「强制读一次即以 pending 收尾」。 +- 决策:窗口一律锚在 POST 发出的时刻。flush 返回且 authority 复核通过之后,调用 `createPerfectPixelReconciliationOperation` 重新设置 `submittedAt = 当前时间`、`reconcileUntil = 当前时间 + 75 秒`,按同一 `operationId` 覆盖本机账本与 dialog,并登记新的 recovery key(key 含 `reconcileUntil`),随后立即 POST。只覆盖时间字段:`request` 与 dialog / operation / task identity 逐字节不变,也不产生第二条账本。 +- 为什么保留 flush 前的预写而不是整体后移:flush 期间另一标签页可能加载同一项目,此时服务端已有带 `perfectPixelOperationId` 的占位,而 localStorage 跨标签共享——本机若还没有账本,那条占位会被直接 hydrate 成 `failed + invalid`。预写的 provisional 账本正好堵住这个可长达数分钟的窗口,因此采用「预写 + flush 后重新锚定」,不采用「把首次账本写入整体挪到 flush 之后」。 +- 人工 exact retry 同此口径:flush 之前继续沿用旧 operation(UI 可以先切到 `generating` 让用户看到重试已开始),flush 完成、authority 复核通过后才重新锚定、覆盖账本与 dialog,然后 POST。先刷新窗口再等 flush 等于把窗口烧在等待上。 +- 明确不在本次范围:flush 本身的无上限等待,以及删除 `strictCompletion` 后每次 PATCH 重起 60 秒 deadline 的连带效果。既然等待是硬前置,给它加上限只会把「慢」换成「409 失败」,不构成改善;真要治需要服务端接受「占位随请求一起提交」,属于接口契约变更。 +- 影响范围:`useImageCanvasGenerationWorkflow.ts` 的 `snapSelectedLayerToPerfectPixels` 与 `retryPerfectPixelOperation`。不修改服务端、SpacetimeDB schema 或对外契约。 +- 同步更新的文档:本文件上一条的错误声明已就地更正;`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md` 中「strict layout save 60 秒预算 / strict revision ACK 前 POST 为零」「未收口 operation 不可删除、不写 delete-generation-result 历史」「durable operation 不得被普通删除路径清理」等已被近几个提交推翻的条款一并修正。历史 commit message 只能靠重写 Git 历史才能改动,不为此改写历史,以本条追加说明为准。 +- 验证方式:两条受控时钟用例分别覆盖首次提交与 exact retry——让 pre-POST flush 期间时钟前进 90 秒(超过整个 75 秒窗口),断言 POST 那一刻账本里是刚建立的完整 75 秒窗口、`submittedAt` 等于 POST 时刻、账本仍只有一条、`taskId` 与 `request` 逐字节未变;retry 用例另断言 flush 期间 dialog 上挂的仍是旧 `submittedAt`,证明窗口没有被提前刷新。两条用例均已实证:回退修复后报 `expected 1800000000000 to be 1800000090000`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 专题文档补齐同步:账本位置、窗口锚点、删除权与孤儿对账 + +- 背景:近五个提交连续翻转了完美像素的多条前端契约,但只追加了 decision-log。`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md` 是这些条目自己声明的「关联文档」,其中仍写着已被推翻的旧契约,实现与验收依据互相矛盾——按旧文档做验收会把当前正确行为判成缺陷。 +- 已更正的条款:①「请求快照写入占位并 flush 布局」→ 账本写本机 `perfectPixelOperationStore`,布局只留 `perfectPixelOperationId` 标记;②「strict layout save 60 秒预算 / strict revision ACK 前 POST 为零」→ 通道已删除,改为 best-effort flush,并写明服务端要求占位此前已持久化(否则 409)、客户端无法证明该前置只能提高概率;③「`submittedAt / reconcileUntil` 从快照写入起算」→ 从 POST 发出时刻起算,首次提交与人工重试同口径;④「POST 前必须取得布局保存成功确认,否则 POST 为零」→ 保存失败不再让 POST 为零;⑤「未收口 operation 不可删除、不写 `delete-generation-result` 伪历史」与「durable operation 不得被普通删除路径清理」→ 任何状态可删且不弹确认,确认只对计费生成成立;TTL 豁免(系统不替用户删)与用户主动删除是两回事。 +- 新增到文档的不变式:标记与账本寿命必须对齐——收口态占位不再写出标记,也不得因「有标记、没账本」被判无效;账本读不到时只有**未收口**占位收口成可删除失败态。恢复必须覆盖孤儿账本,孤儿走一次确定性的读而非轮询,不按 `reconcileUntil` 短路,三种结论的提示口径与清账本规则一并写明。 +- 保留为已知缺口而非静默修正:「普通按钮不得创建第二个 operation」原文是绝对断言,但该保证只由 `existingOperation` 闸提供,而它只扫描内存 dialog 列表;占位可删之后,删掉再从源图发起会产生第二个 identity。文档改为如实描述现状并标注缺口与闭合方向(让本机账本参与防重),代码侧不在本次范围。文档的职责是描述系统实际行为,写一条做不到的保证比留一个标注清楚的缺口更糟。 +- 影响范围:仅文档。不改代码、不改测试。 +- 验证方式:`npm run check:encoding`;核对文档中不再残留「布局保存成功确认」「strict revision ACK」「从快照写入起算」等已推翻表述。 + +## 2026-08-05 legacy 内联账本一次性迁入本机;Undo 复活占位记为已知限制 + +- 背景:账本移出布局后,`hydrateCanvasGenerationDialog` 仍然认布局里的 legacy 内联快照,但**没有任何路径把它写进本机账本**;而 `serializeDialogReferences` 会在下一次保存时把内联快照剥成 `perfectPixelOperationId` 标记。先前提交声称「滚动部署期间的在途操作不会被一次性判死」只对了一半:**第一次 hydrate 活下来,第二次就变成 `failed + invalid`**,永久失去 exact retry 的 identity。 +- 决策:在 `applyProjectSnapshot` 读账本、`splitCanvasLayoutItems` 之后补一次性迁移——把带内联账本、本机却读不到、且**仍未收口**(`generating` / `pending-confirmation`)的 operation 写进本机账本。三个条件都必要:只补写缺失的(本机那份可能刚在 pre-POST flush 之后被重新锚定过,比布局里的新,不能覆盖);只补写未收口的(收口态本就不需要账本,迁移只会造出立刻被裁剪的垃圾条目)。影响范围一次性且有界,仅限部署那一刻仍在途的历史操作。 +- 未采纳:「删除占位后立即 flush 布局,压缩『被放弃的 operation 仍可能往画布插入图层』的竞态窗口」。核查后发现删除**已经**触发既有的 450ms 防抖自动保存(布局自动保存 effect 的依赖里就有 `canvasGenerationDialogs`),所以该改动只能在一个由服务端处理耗时(数秒)主导的竞态里省下 450 毫秒,代价却是让一个高频操作绕过防抖、增加 PATCH 量。收益与代价不成比例,不做。 +- 未采纳:「用户删除未收口占位后从源图重做时弹确认框」。完美像素免费,重复的最坏后果是素材库多一份;为此在常用路径上加一次确认属于给用户制造摩擦。另外账本里能用来匹配同源的只有 `request.sourceResourceId`,纯本地图层根本匹配不到——一个覆盖不全的提醒比没有提醒更容易让人误以为安全。 +- 记为已知限制而非缺陷:删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不再被对账,会一直显示处理中;刷新即自愈,用户也可以再删一次。根因是 `observedPerfectPixelRecoveryKeysRef` 同时承担「并发保护」和「本会话已驱动过」两种语义。已推演的四种修法各有硬伤:删除瞬间剪 key 会被同一轮的 claim 检查重新标记;改成「重新出现时剪」会被 `applyProjectSnapshot` 的整批替换误触发;由删除路径显式清观察记录需要向四个删除入口铺跨 hook 通路;不把未收口 operation 放进可撤销历史则直接砍掉「误删可撤销」。为一个刷新即愈的限制付上述任一代价都不划算,留到重构该记账时一并解决。 +- 影响范围:`useImageCanvasProjectPersistence.ts` 的 `applyProjectSnapshot`。不修改服务端、SpacetimeDB schema 或对外契约。 +- 验证方式:新增「legacy 内联账本在加载后被迁入本机,且剥离内联快照后仍能凭本机账本往返回有效的 `pending-confirmation` 占位」用例,已实证:回退迁移后报 `expected +0 to be 1`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已同步 legacy 迁移要求与 Undo 已知限制)。 + +## 2026-08-05 孤儿账本只在正向终态清除;legacy 迁移判据对齐 exact retry + +- 缺陷一(孤儿过早清账):上一条把孤儿改成「读到任何结论就清账本」,但 `pending` / `conflict` 不是结论。浏览器关掉不会中止服务端处理——api-server 的处理与持久化预算合计可达 90 秒,远端 procedure 也可能还在跑;重开项目时单次 GET 没看见 resource 只说明「还不知道」。此刻清账,服务端稍后落库便再无对账凭据,用户永远等不到「结果已进素材库」的提示,同时又多开一个 identity 的口子。 +- 决策一:只有 `applied` / `dialog-missing` 这两个**正向终态**才清账本;`pending` / `conflict` 与读失败一律保留,留给下次加载重读。残留由 7 天保留期与 32 条上限兜住,代价是极少数永不落库的条目每个会话多一次 GET——比丢失凭据便宜得多。 +- 缺陷二(legacy 迁移漏 `failed`):迁移判据按状态白名单列举了 `generating` / `pending-confirmation`,漏掉 `failed + perfectPixelOperation`。那是旧严格保存失败的合法持久化形状(请求已备好、POST 从未发出),`retryPerfectPixelOperation` 明确接受该状态,面板上的「重试同一完美像素操作」也正是在这个形状下出现。漏迁的后果与缺陷本体一致:下一次保存剥成 marker 后再加载即 `failed + invalid`,重试按钮消失,用户只剩删掉重做——而那正是新 identity,正是 exact retry 存在的意义所在。 +- 决策二:迁移判据改用 `isUnresolvedCanvasGenerationDialogRecord` 取反,与 `hydrateCanvasGenerationDialog` 判定收口态用的是同一个函数,两处不会漂移。**通用要求**:涉及「这条 operation 还需不需要账本」的判断一律问「它收口了没有」,不要列举状态——状态白名单会随着新增状态或语义变化而静默漏项,本条就是实例。 +- exact retry 的价值必须记清楚,它不是「省一次操作」:原样重放同一 identity 时服务端幂等生效(稳定 task / object / resource / asset ID 由 `owner + project + dialogId` 派生,请求带 fingerprint),同内容重放返回 `AlreadyApplied` 而不是再造一份。删掉它意味着对账查不出结论时用户只能新建 identity,旧的若其实成功就会重复。曾评估过「直接删除该功能以减少用户困扰」,权衡后保留——困扰来自文案与状态不清晰,可以单独治理,而幂等保证一旦删掉无法用文案补回。 +- 同步更正的文档:专题文档三处残留矛盾——`submittedAt / reconcileUntil` 仍称「从稳定请求快照写入时建立」(应为 pre-POST flush 之后、POST 之前)、仍称「不会让布局校验失败升级成硬阻断」(应限定为解除了客户端侧拒发,端到端 409 依赖仍在)、以及「删除后不再对账」(应限定为结果不再自动回填画布,对账本身继续进行)。 +- 影响范围:`useImageCanvasGenerationWorkflow.ts` 的恢复 effect 孤儿分支、`useImageCanvasProjectPersistence.ts` 的 legacy 迁移判据。不修改服务端、SpacetimeDB schema 或对外契约。 +- 验证方式:孤儿用例翻转为「单次读仍无法判定时保留账本、不提示、不刷新素材库」;新增「legacy `failed + operation` 被迁入本机」用例。两条均已实证:回退修复后各报 `expected +0 to be 1`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 完美像素的优先级判据:低成本操作不为「丢资源」加限制 + +- 背景:完美像素链路上反复出现同一种压力——为了防止「已经生成的结果丢失关联」,不断有人提议加限制:禁止删除未收口占位、禁止从源图重新发起、让本机账本参与防重。历史上前两条真的加过(2026-08-04 两条决策),造成用户画布上出现删不掉的元素,后来被逐条作废;最近一轮评审又把「删除后可创建第二个 identity」报成必须闭合的 P2 缺口,闭合方向正是第三条。这种压力不会自己停,必须写成判据。 +- 事实前提:该操作免费(`generation_cost_mud_points = 0`)、同步、纯几何规整、不进外部生成队列,重做一次的代价接近于零。它与计费生成的风险结构根本不同,不能套用同一套「必须防止重复提交」的直觉。 +- 决策(优先级判据):凡是「已生成的资源在意外情况下丢失关联、需要用户重做或自行去素材库取回」这一类问题,一律**不构成阻断项**。只有主链路本身出问题才阻断——发起被拒、处理失败、结果没落库、已落库的结果既不回填画布也不进素材库。评审与复查把前一类现象报成必须修复的缺陷之前,先确认它落在主链路上。 +- 决策(禁止的补救方向):不得为了防止上述丢失而新增任何限制用户操作的特性,具体包括但不限于「禁止用户删除占位」「禁止用户重试」「禁止同一张图被处理两遍」。用户对自己画布上的元素始终保有删除与重做的权利;重复处理的最坏后果只是素材库多一份、用户可自行删除,这个代价远小于剥夺用户操作权。已作废的同类封锁不得以任何理由重新引入。 +- 连带处置:先前记为「已知缺口、闭合方向是让本机账本参与防重」的那条,改记为**明确接受的行为**——该闭合方向正是本条禁止的内容。既有的 `existingOperation` 闸是本条确立之前的遗留,方向与本条相反,后续应放宽而非加固。 +- 与 exact retry 的关系:本条不否定 exact retry。它是**用户自愿选择**的幂等路径(原样重放同一 identity,服务端同内容重放返回 `AlreadyApplied`),属于给用户多一个选项,不是限制;被禁止的是把它变成用户唯一能走的路。 +- 影响范围:仅文档与后续评审口径。不改代码、不改测试。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已写入同名判据与禁止清单)。 + +## 2026-08-05 完美像素跨会话续命层记为「迁移到 durable job 时整层清除」 + +- 背景:本分支相对 master 新增约 16758 行,其中测试占 45%(Rust 内联 `#[cfg(test)]` 按行号切开重算:`editor_project.rs` 新增 2882 行里 1221 行是测试,`editor_project_storage.rs` 是 24%)。功能本体极小——像素规整算法在 `pixel_art_snapper.rs` 只改了 100 行。体量几乎全部来自「这条链路没有 durable job」这一个架构选择:免费 + 同步 + 不进生成队列,服务端不留任何「这次请求发出过」的记录,于是「结果是否落库」这个在其它生成路径由 job 行免费回答的问题,必须由客户端自造一整套机制回答。 +- 三层划分(本条的核心结论):**A 层**服务端正确性(单事务原子落库、preflight、归属校验、稳定 object key、预算边界,约 2800 行)与是否有 job 无关,任何形态都保留;**C 层**POST 后一次 GET 对账(applied / dialog-missing / unknown 三档,约 180 行)保护的是「结果未知却谎报失败」,属于主链路,保留;**B 层**跨会话续命(本机账本、刷新恢复与孤儿对账、75 秒窗口与锚点、marker 与账本的寿命对齐、exact retry、inline 占位到期与归属登记,约 1100 行生产 + 2500 行测试)只因为没有 job 而存在。 +- 决策:**现在不删 B 层**——它刚写完、刚测过、刚修完六个缺陷,删除本身是有风险的改动,收益兑现在未来的维护成本上。但**迁移到 `enqueue_editor_generation_job` 时必须整层清除,不得与队列并存**:两套收口机制并行会产生「谁是终态权威」的二义性,比任何一套单独存在都糟。该要求已写入专题文档,作为阶段 4 的验收条件之一。 +- 支撑该结论的实测(免得后来者重新推导):B 层只服务完美像素——`requiresLiveSession: true` 全仓仅一处置位,`claimActiveInlineGenerationDialog` / `releaseActiveInlineGenerationDialog` / `hasActiveInlineGenerationDialog` 的全部五个调用点都在完美像素的提交、重试与恢复路径上。因此整层删除的边界清晰、不会波及其它生成链路。 +- 缺陷密度佐证:2026-08-05 那轮对抗性复查的七条发现里,F1–F6 六条**全部**落在 B 层(F7 是文档同步)。B 层的核心不变式「持久化标记的寿命必须与短寿命本地状态对齐」反直觉且容易写错,是这条链路缺陷最密集的地方。 +- 仓库内既有的廉价答案:手动图集拆分同样免费、同步、无 durable job,客户端只有约 85 行——失败即 `window.alert` 报错,`taskId` 用 `build_prefixed_uuid_id("editor-atlas-split-")` 随机生成,结构上不可能幂等,也没有任何对账。它符合上文的优先级判据,**完美像素的 B 层才是特例**,不得据它给其它链路加同样的机制。 +- 顺带记录的待办(不在本次范围):图集拆分的 catch 只做 `window.alert('拆分图集失败')`,不区分「确定失败」与「网关合成的未知结果」。服务端可能已经切完并落库 N 个 asset 却报失败,用户照提示重拆就会拿到双份——这属于谎报,落在主链路一侧,与「丢资源不阻断」不是一类问题。最小修法是复用现成的 `isGatewayUnknownOutcomeError` 改文案,十几行,不需要照搬 B 层。 +- 影响范围:仅文档。不改代码、不改测试。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已写入三层划分、B 层边界实测与清除条件)。 + +## 2026-08-05 删除确认判据改看 marker,覆盖源准备阶段的免费占位 + +- 缺陷:`requiresGenerationDeleteConfirmation` 的判据是 `status === 'generating' && !dialog.perfectPixelOperation`,而 `perfectPixelOperation` 要到源图解析 / 直传完成后才写入。未登记的本地图片要走 `ticket → PUT → confirm`,预算上限 90 秒;这段窗口里占位是 `generating`、只有 `requiresLiveSession: true`、没有账本,于是判据返回 true——用户删一个**免费**操作会被告知「已消耗的泥点不会返还」。与同日「完美像素占位恢复为可删除」里「任何状态直接删、不弹确认」的决策直接矛盾。已登记资源走短路解析、窗口接近于零,暴露只在未登记本地图片上成立。 +- 决策:`perfectPixelOperationId` marker 从占位**创建那一刻**就写上(`operationId === dialogId` 在创建时已知),判据改看 marker:`status === 'generating' && !dialog.perfectPixelOperationId`。marker 的语义也因此更准确——它表示「这个占位属于一次完美像素操作」,而不是「账本已存在」。 +- 未采纳「删掉弹窗入口」:删除入口全仓只有 `requestRemoveCanvasGenerationDialog` 一条,右键、Delete 快捷键、工具栏全部汇入,完美像素没有自己的删除路径可以摘除。「从本链路删除入口」在实现上等价于「让判据认得出本链路」,绕不开识别问题。真正删掉弹窗只能对所有生成占位一起做,那是另一个产品决定(对计费生成而言该文案是真实信息),不作为修此缺陷的副产品。 +- 未采纳「新增 `generationCostMudPoints` 字段让判据直接问是否计费」:语义上最正,但今天唯一的生产者只有完美像素,图集拆分根本不创建占位,第二个消费者并不存在,属于为一个调用方过度设计。 +- 未采纳「判据加 `requiresLiveSession === true`」:该字段全仓确实只有完美像素一处置位,一行即可修,但它的含义是「只能由本会话收口」而非「免费」。换一个代理不解决问题——**用短寿命字段的存在性判断长期属性**正是本缺陷(以及 F5)的成因模式。 +- 已核过的连带影响:会话内到期清理不受影响,`inlineGenerationPlaceholderExpiryAt` 看的是 `perfectPixelOperation` 而非 marker,源准备阶段被放弃的占位照常到期消失。受影响的只有加载时的快照清理 `dropDeadInlineGenerationPlaceholders`——它的豁免判据接受 marker,因此「源准备中途关标签页」的占位不再被静默清掉,而是在下次加载显示为可删的失败卡。这与「TTL 豁免 = 系统不替用户删,用户主动删除始终允许」一致,判为改善而非退化。id 撞车时 `openCanvasGenerationDialog` 会另生成 id,marker 会暂时指向旧值;它此刻只被当作存在性标记使用(效果仍是不弹确认),写 request 时按真实 dialogId 纠正,不影响任何判等。 +- 测试缺口的根因:原用例的夹具 `durablePerfectPixelDialog` 带着 `perfectPixelOperation`,编码了与判据相同的错误假设,结构上不可能覆盖 operation 形成之前的窗口。夹具已补上 marker 以还原真实形状,并新增「只有 marker、尚无账本」的用例,已实证:回退判据后报 `expected true to be false`。 +- 影响范围:`useCanvasGenerationDialogs.ts` 的判据、`useImageCanvasGenerationWorkflow.ts` 的占位创建。不修改服务端、SpacetimeDB schema 或对外契约。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 布局 flush 不再等待封面链 + +- 缺陷:`flushProjectPersistence` 显式关掉 `queueProjectLayoutSave` 内建的 fire-and-forget 封面分支(传 `persistCover: false`),自己另起一份并在函数最后 `await coverSave`。于是每一个 `await flushProjectPersistence(...)` 的调用方都被挂在封面链后面。封面渲染要为**每个可绘制图层**取 signed URL、再用 `new Image()` 加载——那个 Image 只有 `onload` / `onerror`,**没有 timeout、没有 AbortSignal**,外层的 `try { } catch { }` 只接得住 reject、接不住「永不 settle」。一张图不 settle,生成 POST 就永远发不出去。 +- 归因:这是 2026-08-05「完美像素请求账本移出项目布局」把严格通道与普通 flush 合并成一条路径时引入的**回归**。改动前 `if (requireSuccess) { …; await strictCompletion.promise; return; }` 在封面链启动之前就返回,封面与 pre-POST 路径是结构性隔离的。图集拆分(`void flushProjectPersistence().then(() => splitSelectedIconSpritesheet(layer))`)一直走非严格路径,因此它的暴露是既有的、不是本次引入;但两者同源,一并解开。 +- 影响面分级:**必然发生**的是延迟——封面签名含未量化的 `viewport.x/y/scale`,而完美像素创建占位时 `openPlacedCanvasGenerationDialog` 会 `setViewport(centerViewportOnPlacement(...))`,所以几乎每次调用都会触发全量重渲染(逐图层取 signed URL + 加载 + 渲染 + 上传 OSS + 登记资源),这些与服务端那个 409 前置毫无关系。**可能发生**的是永久挂死,此时 `snapSelectedLayerToPerfectPixels` 的 `finally` 永不执行,图层锁与 inline 占位归属登记被永久持有;用户即使删掉占位,闸的另一半 `perfectPixelLayerIdsRef.current.has(sourceLayer.id)` 仍为真且**静默 return**,本会话内再点完美像素不会有任何反应。图集拆分没有这层脏状态——它的锁在 `splitSelectedIconSpritesheet` 函数体内才取,flush 挂住时根本没被调用,表现只是「点击无反馈」。 +- 决策(删除式修复):删掉 flush 里的 `persistCover: false` 覆盖、独立的 `const coverSave = persistProjectCoverSnapshot(...)` 与末尾的 `await coverSave`,让封面回到 `queueProjectLayoutSave` 内建的 fire-and-forget 分支。参数逐项等价(`layoutInput.viewport` 即 `coverDisplayViewport`、同一个 `refs.layersRef.current`、flush 不传 `delayMs` 故走立即分支),**封面照存,只是不再有人等它**。未采纳「给 flush 加一个跳过封面的选项」:那会把同一个结构性问题留在图集拆分身上,并且多一个需要每个调用方正确设置的开关。 +- 代价:`returnToProjects` 不再等封面就跳转。该保护本就很薄——跳转是 SPA 路由切换而非页面卸载,fire-and-forget 的 promise 在同一 JS 上下文里会跑完;真正会打断它的是浏览器关闭/硬刷新,而 `await` 在 `beforeunload` 里同样救不了。实际损失只是「点返回后立刻关标签页」这个窄窗口里封面可能没传完,而封面是缩略图、下次任意保存会重新生成。 +- 遗留(建议单开,不在本次范围):`loadProjectCoverImage` 里无 timeout / 无 AbortSignal 的 `new Image()` 本身仍是隐患,自动保存路径一样会踩。本次只是把它移出生成链的关键路径,没有消除它。 +- 影响范围:`useImageCanvasProjectPersistence.ts` 的 `flushProjectPersistence`。不改服务端、不改契约。 +- 验证方式:既有用例「flush 等待封面缓存」翻转为「flush 不等封面、但封面链照常跑完并完成上传与资源登记」;新增「封面永不 settle 时 flush 仍返回」——用永不 resolve 的 blob 模拟 `new Image()` 不 settle,并断言 `createProjectCoverSnapshotBlob` 确实被调用过以防用例空过。已实证:回退修复后新用例报 `expected 'false' to be 'true'`。运行 `npx vitest run src/components/image-editor src/components/platform-entry src/services`(101 文件 / 1241 项)、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index cfe8ceb4f..430d0258c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,21 @@ - 关联:相关文件、文档、提交或 Issue ``` +## `timeout_at` 不能替代显式的预算耗尽预检 + +- 现象:给完美像素加端点级并发闸后,预算已经耗尽的请求仍然能拿到许可,白占一个名额继续去打几轮全账号 SpacetimeDB 扫描,直到下载那步才失败。 +- 原因:`tokio::time::timeout_at` 会先 poll 一次内层 future 再判超时。信号量有空闲许可时 `acquire_owned()` 首次 poll 就绪,于是即使 deadline 早已过去,返回的仍是 `Ok(Ok(permit))` 而不是超时。既有 `acquire_editor_pixel_art_cpu_permit` 里那句 `if Instant::now() >= processing_deadline` 正是为此存在,新写的许可函数漏掉后被单测抓出。 +- 处理:所有「先判预算、再等资源」的获取函数都必须在 `timeout_at` 之前显式判一次 `Instant::now() >= deadline` 并直接返回超时错误;这句不是冗余防御。同理,进入排队计数之前也要先做这个预检,避免为注定失败的请求占用队列名额。 +- 验证:在有空闲许可时用已过期的 deadline 调用获取函数,只断言返回 `504` 而不是许可;`504` 已足以证明显式预检没有被 `timeout_at` 的首次 poll 绕过。禁止在该用例里读取进程级队列 Atomic 的 before/after;相对断言同样会被并行测试插入。仅靠「信号量占满时超时」的用例发现不了这个问题。 +- 关联:`server-rs/crates/api-server/src/editor_project.rs`(`acquire_editor_pixel_art_snap_permit`、`acquire_editor_pixel_art_cpu_permit`)。 + +## 有界等待队列的计数递减必须写在 Drop 里 + +- 现象:给同步端点加「最多 N 个等待者」的保险丝时,若把计数递减写在正常返回路径上,客户端断连或超时触发会让等待中的 future 被丢弃而跳过递减;计数只增不减,最终队列永久判定为满,接口对所有人返回 `503` 且不会自愈。 +- 原因:Rust 的 async future 可以在任意 await 点被取消,取消时只保证 `Drop` 会跑,不保证后续代码会执行。有界队列的入场与离场天然不对称。 +- 处理:把递增封进一个 guard 结构体,递减放在它的 `Drop` 实现里;递增本身用 `fetch_update` 的 CAS,不能用「先读后加」——两个线程同时读到 `max - 1` 各自加一就会越界。拿到资源后立即 `drop(guard)` 让出队列名额,不要让它跟着许可一起活到请求结束。 +- 验证:单测覆盖 CAS 边界(满了返回失败且计数不越界、上限为 0 时任何进入都失败),并由独立用例覆盖 guard 离开作用域后的计数归还。预算耗尽路径只断言 `504`,不得通过另一个测试也会修改的进程级 static before/after 来推断“未入队”,也不得用串行锁或 `--test-threads=1` 掩盖隔离问题。 +- 关联:`server-rs/crates/api-server/src/editor_project.rs`(`try_enter_bounded_queue`、`EditorPixelArtSnapQueueGuard`)。 ## Linux 生产脚本门禁不能假设本地也是 GNU userland - 现象:macOS 本地运行维护页、生产 API 部署和 Rust 产物门禁时,依次出现 `mv: illegal option -- T`、`mapfile: command not found`、`/usr/bin/cp` / `/usr/bin/chmod` 不存在,以及 `.rlib` 明明含有 `.o` 却报告“没有可扫描成员”;安全修复计划还会把 `/var/folders` 到 `/private/var/folders` 的系统别名误判为用户符号链接。 @@ -3236,9 +3251,9 @@ - 现象:release 上 api-server 周期性出现全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 业务超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),`/healthz` 仍 200,只有重启能恢复,过若干小时复发。 - 原因:旧 `PooledConnectionLease` 只能显式 `release_connection` 归还;HTTP 请求方在等待 StDB 回包期间断开时 handler future 被取消,permit 自动归还但槽位 `in_use` 永不复位。后续 acquire 在拿到 permit 后进入无界 `loop + yield_now` 扫描空闲槽位,泄漏积累到 pool_size 后整池挂死。 -- 处理:租约持有 `Arc` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。 +- 处理:租约持有 `Arc` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。该保证只覆盖本地 lease / slot / permit 回收;RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果仍须按 unknown 读取权威事实。 - 验证:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(`dropped_lease_releases_slot_and_permit`、`acquire_times_out_at_pool_acquire_when_pool_is_busy`)。 -- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`。 +- 关联:`server-rs/crates/spacetime-client/src/active.rs`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`。 ## 后台灰度配置不能从 SpacetimeDB 本地表缓存读取 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 738f539e3..4871d3a8a 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -38,13 +38,30 @@ - 普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 三个面板增加紧凑的 `像素艺术` 勾选项;移动端可独占一行,但不增加功能说明文案。当前生成对象以 `style: "none" | "pixelArt"` 保存选择并随现有请求 / 队列 payload 传递;该字段不写入用户可见 `generationInputs`,也不新增素材元数据字段。其它生成、编辑、UI 素材提取、角色动画及画布 Agent 入口不展示或设置该选项。 - `style` 是可选字符串兼容边界。省略、`null`、空字符串和 `"none"` 统一归一为内部 `None`,不返回告警;`"pixelArt"` 仅允许普通图片(`kind` 省略)与 `kind="character"`,图标图集请求单独允许该值。未知字符串或在 `spec / quick-edit / ui-design / publication-material` 等不支持的图片 `kind` 上请求 `"pixelArt"` 时,按 `None` 完成原管线并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍是畸形请求并返回 `400`。旧 payload 缺少字段时等价于 `None`。 -- `None` 必须保持现有生成、尺寸处理、BgFilter、上传、资源和画布链路不变。`PixelArt` 只增加父流程内的纯内存 Rust 后处理,不启动 Python 或独立服务,也不改变 BgFilter 的 `flat` 参数、Alpha 回贴、`cross_check`、fallback 或默认关闭 despill 的现有行为。 +- `None` 必须保持现有生成、尺寸处理、BgFilter、上传、资源和画布链路不变,包括提交给 provider 的提示词必须与未带该字段时逐字一致。`PixelArt` 在父流程内做两件事:向提交给 provider 的提示词追加一行像素风约束,以及在 provider 回图后执行纯内存 Rust 像素规整。两者都不启动 Python 或独立服务,也不改变 BgFilter 的 `flat` 参数、Alpha 回贴、`cross_check`、fallback 或默认关闭 despill 的现有行为。 +- 2026-08-01 修订:`PixelArt` 增加提示词端约束。snapper 是几何对齐器,横纵两轴都检测不到网格步长时会退到 `min(width,height)/64` 统一网格兜底,产出马赛克而非像素画;因此在提示词端要求 provider 本身就输出块状结构。注入点固定为 `generate_editor_image_for_owner` 与 `generate_editor_icon_spritesheet_for_owner` 构造提交提示词的位置,包住既有 builder 的返回值,builder 签名和既有输出契约不变,三个入口(登录态路由、外部 API v1、异步 job worker)一处覆盖。约束句按链路分三条、追加在末尾并独立成行:普通图片用「画面为像素风格」;角色形象和图标图集生成后都要按纯色抠像,绿幕底必须保持平整,分别用「角色主体为像素风格」和「每个图标素材均为像素风格」,不得出现「画面」级别的像素化要求。实测只提「像素风格」已可接受,不注入网格密度、色板色数和抗锯齿等约束。约束句是否进入 `editor_project_resource` 的 prompt 列按链路而定:普通图片会进;角色形象在抠图成功后 `output_prompt` 被无条件覆盖为 `"去除纯色背景"`,其原图 resource 存的是用户原文,因此约束句不进角色的任何 project resource;图标图集的原图 spritesheet resource 会进,透明结果和切片则分别存 `"去除纯色背景"` 与 `"自动拆分图集"`。角色链路的完整提交提示词是否留存取决于 provider——asset object 元数据写的是 `actual_prompt.unwrap_or(prompt)`,provider 回了 `actualPrompt` 就存 provider 改写后的文本,此时 `submitted_prompt` 在系统内一处都不落(审计只记 `promptChars`)。响应体也不一致:普通图片和角色形象返回用户原文,前端显示不变;图标图集返回的是含约束句的工程化 `prompt`,调用方可直接看到模板内容。不新增 OSS PUT、项目资源、素材记录或画布图层。以上 prompt 列写入、asset object 元数据和响应字段规则全部是既有行为,本次未改动。 +- 2026-08-01 修订:画布四个生图入口(普通图片与角色形象共用一条、UI 设计图、修改图片的两个 provider 分支)的 negative prompt 移除「低清晰度」。该词按字面否定低分辨率,与以低分辨率重采样为本质的像素风直接对冲。其余玩法的同名词条不在本次范围内。 - 普通图片与角色在 provider 回图后先按统一业务像素矩阵尝试交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。普通图片随后以这张实际交付尺寸图同时作为网格分析源和 RGBA 采样源;角色先持久化同尺寸平底原图并交给 BgFilter,正常成功后把 Alpha 蒙版回贴到该平底原图,再以平底原图分析网格、以透明 RGBA 图采样。图标仍以已持久化的平底 provider 图尺寸为基准,BgFilter 成功并回贴 Alpha 后执行同样的双输入规整。固定首版参数为:分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、相邻边缘峰间距使用线性插值 `P30` 估算步长、固定色板关闭、K-means 最大采样 `262144`。 - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均按像素后处理失败的 best-effort 规则保留进入该步骤前的图片。 - 单格颜色按 `Σ(A × RGB) / ΣA` 进行 Alpha 加权;单格覆盖率按 `Σ(A / 255) / N` 计算。覆盖率大于等于 `0.375` 且 `ΣA > 0` 时输出硬 Alpha `255`,否则输出严格的 `[0,0,0,0]`;最终 Alpha 只允许 `0 / 255`。分析用 16 色只负责网格识别,不限制最终输出色数。 - 逻辑低分辨率图只存在于内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,并直接替换原本即将持久化的最终图片字节。普通图片和角色的该输入已经过前置 Lanczos 交付尺寸归一,或在无法安全归一时保留 provider 实际尺寸;图标输入以已持久化平底原图的实际尺寸为准。nearest 不替代前置尺寸归一,规整完成后不再执行第二次 Lanczos 或其它尺寸恢复。角色和图标应复用 Alpha 回贴阶段已经读取的平底原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。 - 像素模式的持久化增量必须为零:普通图片仍只上传原有一张最终主图;角色仍只保留原有 provider 原图与透明主图;图标仍只保留原有 provider 原图、透明图集和实际成功的切片。禁止保存逻辑低分辨率图、像素化前后双份主图、预览图、网格诊断图或报告,禁止新增 asset / resource 类型、项目资源、画布 item、队列 job kind 或数据库字段。 - 像素后处理属于 best-effort:失败时保留进入该步骤前的图片,继续原有最终上传与画布完成,并通过既有通用 `warning` 返回非阻断原因,不把任务改为失败或退款。BgFilter 自身失败时仍按原 source-only fallback 收口,像素处理不运行;图标后处理成功后再执行原有自动拆分,拆分告警继续使用现有 `sliceWarning` 语义。 +- 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建同尺寸 PNG 派生结果;音频、视频、图片序列和 `character-animation` 不显示该按钮。 +- 已有图片像素规整固定调用登录态同源 `POST /api/editor/images/pixel-art-snaps`,复用同一纯内存 Rust snapper、CPU 并发许可和输入尺寸上限。该入口免费、只走当前 HTTP 请求内的 inline 处理,不创建 `external_generation_job`,不刷新或自动打开任务侧栏,也不进入泥点扣费 / 退款链路。它另有一层端点级并发闸(最大 4、等待队列上限 2048),设在首次 IO 之前;队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`。30 秒总预算从 handler 入口起算,覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整,不是只算 CPU 部分。 +- **完美像素的优先级判据(约束实现、评审与复查)**:该操作免费(`generation_cost_mud_points = 0`)、同步、纯几何规整,重做一次的代价接近于零。因此凡是「已生成的资源在意外情况下丢失关联、需要用户重做或自行去素材库取回」这一类问题,一律**不构成阻断项**;只有主链路本身出问题才阻断——发起被拒、处理失败、结果没落库、已落库的结果既不回填画布也不进素材库。把前一类现象报成必须修复的缺陷之前,先确认它落在主链路上。 +- **明确禁止的补救方向**:不得为了防止上述丢失而新增任何限制用户操作的特性,具体包括但不限于「禁止用户删除占位」「禁止用户重试」「禁止同一张图被处理两遍」。用户对自己画布上的元素始终保有删除与重做的权利;重复处理的最坏后果只是素材库多一份、用户可自行删除,这个代价远小于剥夺用户操作权。历史上引入过的同类封锁(未收口 operation 不可删除、随源图层清理豁免)已被逐条作废,不得以任何理由重新引入。既有的 `existingOperation` 闸(占位仍在时拦住从源图重新发起)是本条确立之前的遗留,方向与本条相反,后续应放宽而不是加固——尤其不得改成「让本机账本也参与防重」,那正是被本条禁止的「禁止一张图处理两遍」。 +- **跨会话续命层的存废条件(迁移到 durable job 时必须整层清除)**:完美像素的客户端机制按存在理由分三层。**A 层**——服务端单事务原子落库、preflight、归属校验、稳定 object key、预算边界——与是否有 job 无关,任何形态都要保留。**C 层**——POST 之后立刻一次 GET 对账,把结果分成 applied / dialog-missing / unknown 三档——保护的是「结果未知却告诉用户失败」这类谎报,属于主链路,也要保留(约 180 行)。**B 层**——本机请求账本 `perfectPixelOperationStore`、刷新后的 GET-only 恢复与孤儿对账、75 秒绝对窗口与它的锚点、`perfectPixelOperationId` 标记与账本的寿命对齐、exact retry、inline 占位到期与跨标签页归属登记(`useInlineGenerationPlaceholderExpiry`)——**只因为这条链路没有 durable job 而存在**,它提供的全部能力可概括为「关掉浏览器后还能找回结果 / 不重复」,而这两件事已被上文的优先级判据明确判为可接受损失。一旦完美像素改为走 `enqueue_editor_generation_job`,B 层的能力由队列与 job 行提供,**必须整层删除,不得与队列并存**——两套收口机制并行会产生「谁是终态权威」的二义性,比任何一套单独存在都糟。 +- B 层的边界是可验证的、且已确认只服务完美像素:`requiresLiveSession: true` 全仓仅有一处置位(完美像素提交路径),`claimActiveInlineGenerationDialog` / `releaseActiveInlineGenerationDialog` / `hasActiveInlineGenerationDialog` 的全部五个调用点也都在完美像素的提交、重试与恢复上。直接体量:`perfectPixelOperationStore.ts` 203 行(测试 228 行)、`useInlineGenerationPlaceholderExpiry.ts` 140 行(测试 401 行)、`hydratePerfectPixelOperation` 128 行,加上工作流里的恢复 effect 与窗口锚定,生产代码约 1100 行、测试约 2500 行(后两个数字是估算,前面几个是实测)。同为免费、同步、无 durable job 的手动图集拆分只用约 85 行客户端代码(失败即报错,`taskId` 用随机 UUID,无幂等、无对账),是本仓库对同类问题的既有廉价答案;完美像素额外的 B 层是**特例而非范式**,不得据它给其它链路加同样的机制。 +- 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后把版本化 `perfectPixelOperation` 请求快照写入**本机账本**(占位本身只带 `perfectPixelOperationId` 标记)并 flush 当前项目布局,最后才发送 POST。`canvasCompletion.dialogId` 同时作为 operation identity、稳定 task identity 的输入和本地源图上传 ID;同一 operation 的上传路径与后续 POST 请求都不得随机漂移。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片只执行 `ticket → OSS PUT → confirm → objectKey`,不为这条持久化输入换取 signed URL。一个 `AbortSignal` 必须贯穿源文件 fetch / 图片解析边界、ticket、PUT、confirm,完整上传 helper 的可选换签也必须透传同一 signal。正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。 +- 源准备与 operation journal 使用两段绝对预算:`ticket → PUT → confirm` 连同源解析共用 90 秒;confirm 成功后形成稳定 `perfectPixelOperation` 并**同步写入本机账本**(`perfectPixelOperationStore`,owner + project 双键的 localStorage),布局里只留 `perfectPixelOperationId` 标记。原先的 strict layout save 通道(60 秒绝对预算、revision ACK 前 POST 为零)已整体删除:账本不再寄生在用户布局上,本机写入不过网络也不受服务端校验影响,同样能保证请求可被追溯。被解除的是**客户端侧**「拿不到 revision ack 就拒发」这一层阻断;端到端依赖仍在——布局 PATCH 被校验拒绝、占位因此从未落库时,POST 仍会被服务端以 409 拒收。POST 前仍然 `await` 一次 best-effort 布局保存——服务端要求占位**此前已经持久化**,否则 `validate_editor_pixel_art_snap_placeholder_exists` 直接 409;但 best-effort 不再提供成功 ACK,因此客户端**无法证明**该前置已满足,只能提高满足它的概率(占位可能已由此前的自动保存落库,PATCH 也可能成功而 ACK 丢失)。该 flush 没有整体上限,所以 75 秒对账窗口必须在 flush 返回、authority 复核通过之后才锚定,且首次提交与人工重试同此口径;锚定只覆盖 `submittedAt / reconcileUntil`,按同一 `operationId` 覆盖账本,request 与 dialog / operation / task identity 逐字节不变。此阶段失败持久化为 `failed + perfectPixelOperation`,保留同一 `sourceImageSrc / dialogId / taskId / request`;重试请求必须与账本中的 POST JSON byte-for-byte 一致且不得重新上传。**明确接受的行为,不是缺口**:占位恢复可删除之后,用户删掉未收口占位再从源图发起会得到第二个 identity,旧的服务端操作若迟到落库就会多出一份素材,两个 `taskId` 无法幂等合并。按上文的优先级判据,这属于「已生成资源丢失关联」而非主链路故障,代价是用户自行删掉多余素材,**不得**通过让本机账本参与防重来「闭合」——那是被明令禁止的「禁止一张图处理两遍」。confirm 成功后浏览器在 operation 首次 PATCH 落库前立即崩溃仍可能留下 object-only 记录;完全消除该窗口需要服务端 durable upload journal,不属于当前前端修复。 +- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;不得保存逻辑低分辨率图、诊断图或前后对比图。 +- strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。后端先纯计算精确 object key 和候选 project resource,再调用只读 SpacetimeDB preflight 校验自定义素材目录归属、复用权威 completion planner,并执行 legacy / structured 的 2 MiB 总量与 512 KiB 单项门禁;默认目录尚未创建时允许通过,preflight 不写库。preflight 与 PUT / HEAD / 原子 persist 共用 60 秒绝对 deadline;preflight 失败或超时不得 PUT,也不得带 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交,并重新校验目录、布局、幂等身份与 revision。preflight 不加锁或 reservation,所以通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下 OSS 孤儿对象;这是本次最小修复明确保留的 TOCTOU 边界。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。 +- `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 在 pre-POST flush 返回、authority 复核通过之后、POST 发出之前建立统一 75 秒绝对窗口(该 flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光),POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。 +- unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`(账本在本机,布局只留 `perfectPixelOperationId`)。**用户可以随时删除该占位**,任何状态都不例外、也不弹确认:删除不撤销任何在途请求,结果照常落库并进素材库,服务端发现 dialog 已不在会返回 `DialogMissing`;封锁用户删除自己画布上的元素不是可接受的代价。删除后**结果不再自动回填画布**(服务端发现 dialog 已不在会返回 `DialogMissing`),这是用户主动放弃的结果,不得判定为缺陷;但对账本身不会因此停止——当前标签页已经在飞的 Promise 会继续读到终态,本机账本也会以孤儿身份在下次加载被读一次,结果确已落库时仍会提示用户去素材库取。未删除时用户可继续 GET 对账或显式按原 identity 重放。人工重试在 pre-POST flush **之后**才刷新观察窗口(同上一节的锚定口径),POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstone,completion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。 +- 删除 generation dialog 的按钮、快捷键和右键菜单必须在写画布历史、清选择或执行低层移除前经过同一请求保护入口。未收口完美像素 operation 与其它占位同样可被立即删除,写正常的 `delete-generation-result` 历史并清理 identity;删除确认只对**计费**生成成立(现成弹窗讲的是「已消耗的泥点不会返还」,而完美像素 `generation_cost_mud_points = 0`),判据收敛为具名的 `requiresGenerationDeleteConfirmation`。低层 `removeCanvasGenerationDialogById` 必须无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因。 +- 完美像素并发闸回归测试不得通过进程级队列 Atomic 的 before/after 判断“本用例未入队”。过期 deadline 用例只断言 `504`;queue guard 的 Drop 归还由独立用例覆盖,不引入 `--test-threads=1`、全局串行锁或其它串行化兜底。 +- 项目快照对账生成占位时必须检查全部同 ID 原始记录,不得用首项短路:通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不得按首条记录猜测成功。 ### 角色动作帧抠图像素边界 @@ -68,6 +85,7 @@ ## 数据与持久化 +- 完美像素派生结果仍复用既有 `asset_object / editor_project_resource / editor_asset / canvasCompletion` 数据模型,不新增表、资源类型或队列类型,但四类数据库事实改由单个 procedure 原子持久化。源图已有正式 project resource 时,成功结果的 `sourceResourceId` 指向该资源;最终 PNG 只 PUT 一次,同一 operation 至多对应一个 project resource 和一个账号素材。像素处理本身失败时三类业务写入都不发生;像素处理成功但事务内读取的权威占位已删除时,object / resource / asset 同事务提交并返回 `DialogMissing`,canvas / revision 保持不变,不能用提交时旧 placeholder 把图层重新插回。 - 新增 `editor_project` 表保存图片画布工程:`projectId`、`ownerUserId`、标题、创建时间和更新时间;历史 layout 字段暂保留为兼容列,不再作为权威画布数据。 - 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。 - 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。 @@ -115,6 +133,7 @@ - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和完整用户需求生成 spritesheet;为兼容现有契约,画布前端把完整文本作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。切片只在有界管线中按需编码,共享单个 HTTP client 并以最多 `2` 路并发执行 OSS `PUT + HEAD`;client 的连接与单请求超时分别固定为 `10s / 60s`,手动入口在下载最大 `32 MiB` 来源对象前取得 memory admission,上传收齐后立即释放整图 admission,不跨数据库等待持有。所有对象验证通过后,由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中批量确认 `asset_object`、创建 project resource / account asset 并写入 cohort 完成事实,不得逐片发起三组 procedure 或在部分素材落库后伪造完整批次。resource / asset ID 由 owner、task 与切片序号稳定派生;同一批次不确定结果后重放只能复用内容完全一致的素材,冲突内容必须拒绝,来源资源还必须存在且与派生资源属于同一 owner / project。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 +- `POST /api/editor/images/pixel-art-snaps`:对已登记的静态图片执行免费的同步完美像素化。请求使用 `sourceImageSrc` 承载当前 owner 可读取的 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,`sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel` 可选;拒绝内联媒体、未登记对象和非静态栅格素材。客户端提交的 `generationInputs` 必须与其余生成入口一样先经 `sanitize_editor_client_generation_inputs` 剥离 `screenColorHex / mattingProvider / mattingModel` 三个服务端保留审计字段,再进入任何 IO——这三项是背景色决策与 bgfilter 实际执行后由服务端写入的处理事实,不接受客户端声明;本端点是纯几何规整、不抠图,任何 matting 元数据出现在这类记录上本身就是伪造。源图已有正式 project resource 时前端应带上 `sourceResourceId`:该资源随 owner-scoped 项目读取一并鉴权,服务端可直接取用其 objectKey,省去按注册 ID 的全账号项目与素材库扫描;此时 `sourceImageSrc` 应传该 objectKey 或同一个 `resourceId`,两者指向不同图片会被直接拒绝。不带 `sourceResourceId` 时仍需按注册 ID 解析,但全账号项目与素材库只取一次快照,注册 ID 解析、归属校验和跨记录 `assetKind` 收集全部在该快照上用 `_from_records` 纯函数完成,命中已登记记录即短路、两份记录都查不到才回落 asset object 点查;不得再调用内部自带两轮扫描的 `resolve_editor_reference_object_key_for_owner`。`get_editor_project` 到来源解析结束整体套同一份绝对处理预算,超时返回 `504` 且文案指向归属校验——预算从 handler 入口起算不等于覆盖该阶段,裸 `await` 会让请求一路走到下载才发现预算耗尽,并全程占用端点准入名额。像素处理使用 strict 失败语义且不进入外部生成队列;成功时只持久化一张最终 PNG,并返回对应 project / resource / asset 快照。服务端把规范化 `canvasCompletion.dialogId` 作为 operationId,以 owner / project 共同限定作用域,并从该 operation 稳定派生 task、asset object、resource、asset 身份;请求 fingerprint 覆盖来源 object key、来源与输出字节摘要、来源资源、素材类型、规范目录 / 标签、规范 generationInputs、completion 和算法版本。OSS PUT / HEAD 之后只调用一次原子 SpacetimeDB procedure;权威 dialog 仍存在时在源图右侧完成占位,已删除时只提交 object / resource / asset 而不推进 canvas revision。完整同内容重放返回 `AlreadyApplied`,同 operation 输入漂移或只有部分记录存在返回幂等冲突。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 - 图片生成请求边界:角色生成、图标 spritesheet 和 UI 素材提取的同源画布 request DTO 保留 `segModel`,前端不提供选择控件而是自动提交默认 `birefnet`;api-server 继续校验并在字段缺失时回落默认值。`background_mode`、`cross_check` 与角色动作逐帧去背的 `seg_model` 不属于前端请求字段,只在 api-server 到 loopback worker 的内部 RPC 中传递。请求中的 `segModel` 不进入用户可见 `generationInputs` 或任何普通用户响应。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数;nanobanana2 路径保留 provider 的比例 / 清晰度请求,但两条路径回图后都以统一业务目标尺寸尝试归一。只允许缩小和轻微裁切;回图任意一边小于目标或比例偏差过大时保留 provider 实际回图及尺寸,并返回通用 `warning`,不得放大伪造所选档位。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。provider 对齐尺寸或原生 K 档像素不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 @@ -160,7 +179,13 @@ - 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。 - Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”;有效图片结果的普通单击也直接通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层。结果卡片不声明按钮语义或 `tabIndex`,Enter 和 Space 不得触发定位;视频和音频的普通点击及原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,明确失败的占位保留错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放;纯校验、排队或预算等明确未进入结果持久化的响应可直接失败,transport、网关、abort、客户端超时或 `details.resultPersistenceStarted = true` 属于未知结果,必须按下列 durable operation 与 GET-only 契约收口。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。 +- 裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 +- 2026-08-04 修订:完美像素前端已经让素材刷新退出 verdict,持久化 operation 请求快照与 `pending-confirmation`,并接入刷新后的 GET-only 恢复;是否成功只能由下面的项目 GET 正向证据判定。 +- 完美像素以 durable operation 为提交边界:请求账本 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }` 存在**本机** `perfectPixelOperationStore`(owner + project 双键的 localStorage),其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,`submittedAt / reconcileUntil` 构成 **从 POST 发出时刻起算**、不得被 POST 回包续期的 75 秒整链绝对窗口——pre-POST flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光。项目布局里只保留 `perfectPixelOperationId` 标记,用于把这类占位与队列型占位区分开。**标记与账本的寿命必须对齐**:账本在收口那一刻清除,因此收口态占位(带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`)既不再写出标记,也不得因为「有标记、没账本」被判成无效——服务端完成 completion 时只做字段级改写、从不摘标记,任何忽略这一点的判据都会把每一次成功判成失败。账本读不到(换设备、清缓存、隐私模式、配额写满)时,**未收口**占位收口成可删除的失败态,不得据此阻断用户删除或重做。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前仍需 `await` 一次 best-effort 布局保存(服务端要求占位此前已持久化,见上文 409 条款),但保存冲突、鉴权失败或重试耗尽**不再让 POST 为零**——账本已在本机、请求可被追溯,客户端照常发出,由服务端裁决。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。 +- 完美像素 unknown-result 的 verdict 只来自项目 GET,POST 响应体不得直接判成功:找到唯一稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`;resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;dialog 仍 generating(包括匹配 resource 已先可见)或 dialog 不存在且无匹配 resource 时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。首个 GET 立即执行,此后退避不超过 5 秒;即使绝对窗口已过期也必须读取一次。GET 的绝对 deadline 从进入 `requestJson` 起覆盖鉴权恢复、所有 attempt 和响应体读取;不能把只覆盖响应头的 `timeoutMs` 当成整次读取上界。单次 deadline 到期按一次读取失败处理,随后由轮询返回 `pending`,首次提交必须进入 `finally` 释放 dialog ownership 与图层锁,hydrate 恢复必须清理 recovery controller。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断,同步 throw、异步 reject 和永久挂起均不得阻塞。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败或声称素材已保存。 +- 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 账本的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。恢复还必须覆盖**孤儿账本**——本机有账本、布局里却没有对应占位,这正是「POST 已发、布局尽力保存没落盘、标签页关闭」的结局。孤儿走一次确定性的读(不轮询):`reconcileUntil` 只描述「结果可能还在飞」,而孤儿来自已经消失的会话,按它短路会让这条兜底分支在唯一的目标场景(稍后重开,必然晚于 75 秒)下永不生效。收口口径:`dialog-missing` 提示结果只进素材库;`applied` 静默刷新素材库(读到的就是当前权威状态,结果本就在眼前);未落库完全静默。三者都清账本,读失败不清——那是「不知道」而非「知道没有」。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。带 operation 的占位不受 `requiresLiveSession` TTL 清理(系统不替用户删),但**用户主动删除始终允许**——两者是不同的事。TTL 只兼容完全没有 operation 标记字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。滚动升级期间从布局读到的 legacy 内联账本必须在 hydrate 后一次性迁入本机账本——布局里的内联快照会在下一次保存时被剥成标记,不迁移就再没有任何路径能补写,部署那一刻仍在途的操作会在第二次加载失去 exact retry identity。**已知限制**:本会话的「已观察」记账按 operation 记录,用于避免同一次 operation 被并发轮询两遍;若用户删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不会被重新对账,会一直显示处理中。刷新页面即自愈(hydrate 会按标记与账本重新判定),且用户随时可以再删一次。该记账同时承担「并发保护」与「本会话已驱动过」两种语义,要根治需先拆开这两件事;在此之前不接受以「删除路径显式清观察记录」等跨 hook 埋线的方式局部绕过。 +- legacy inline 占位的本会话归属必须由同一份封装 ownership 管理:同步 Set 在首个 await 前完成 `claim`,保证到期判定即时可见;`claim / release` 仅在 membership 真变化时推进 React 可观察的 version,`release` 即使发生时 dialogs 与 callbacks identity 都不变,也必须立即唤醒到期 effect 重新判定。禁止重新暴露可变 Set ref 或直接修改 `.current`,React 不会因为 ref 内容变化而重跑 effect。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 diff --git a/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md b/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md index c8045545f..586b88971 100644 --- a/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md +++ b/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md @@ -2,7 +2,7 @@ - 日期:2026-06-11 - 关联故障:release 环境 api-server 周期性全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),重启后临时恢复。 -- 涉及代码:`server-rs/crates/spacetime-client/src/lib.rs` +- 涉及代码:`server-rs/crates/spacetime-client/src/active.rs` ## 故障根因 @@ -28,6 +28,8 @@ 3. acquire 改为 CAS 抢占槽位:持有 permit 即保证并发持有者不超过 `pool_size`,扫描一轮必然命中空闲槽位,彻底删除自旋循环;建连失败直接返回错误,槽位由租约 Drop 复位。 4. `release_connection` 退化为 `drop(lease)`,显式与隐式归还共用同一条兜底路径。 +这里的“取消安全”只指本地连接租约、槽位与 permit 可回收。RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果必须按 unknown 读取权威事实对账;`dropped_lease_releases_slot_and_permit` 只覆盖本地 Drop,不构成远端取消测试。 + ## 验收 - `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(44 通过,含上述连接池与缓存连接测试) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 8e145d8fc..023ffc138 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -244,9 +244,13 @@ npm run check:server-rs-ddd 7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。 8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。 9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 +10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。 ## 外部服务与资产 +- 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。 +- 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。完整同内容既有记录只读返回 `AlreadyApplied`,不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 +- 完美像素 unknown 与并发闸测试边界:上一条末句“结果未知时先 GET 权威项目 / 素材快照”的旧表述已撤回,项目 GET 才是唯一结果 verdict;素材刷新只允许在项目终态后 best-effort 触发,不能参与成功判断。无 dialog 只有同时存在匹配稳定 task 的唯一 resource 时才是 asset-only 成功,否则保持 unknown。过期预算用例只断言返回 `504`,不得读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 的 before/after;queue guard 的 Drop 归还由独立用例覆盖。不得用相对断言、`--test-threads=1` 或全局串行锁掩盖并行竞态。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 @@ -605,6 +609,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorCanvasGenerationDialog` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布生成对话框表,保存 canvas / project / owner、生成模式、状态、可选 source / generated layer、占位几何和有界扩展字段;typed 列为快照真相,`dialog_json` 只保留未结构化参数。当前 worker completion 以读取时 revision 调用 CAS 保存,冲突时拒绝覆盖;`job_id + worker_id + lease_token` 栅栏下的资源 / layer / dialog / job 单事务完成仍是后续收口。 +- 完美像素 completion:同步处理成功后按当前 revision 重新读取权威 dialog;目标 dialog 存在时只写入一个派生 layer 并关联结果,目标 dialog 的删除已先持久化时跳过 layer / dialog 写回,不得按请求快照重建占位。该分支不属于 external job completion,允许此前已成功创建的 resource / asset 保留。 - 索引:按 canvas 和 project 读取结构化行;当前未建立 external job 二级索引。 ### `editor_canvas_layout_migration` @@ -619,6 +624,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorProjectResource` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和历史 `public_showcase_enabled`。`asset_kind` 是跨布局共享的资源默认素材类型;单个结构化图层的差异只写 `editor_canvas_layer.asset_kind_override`,不能通过新增资源行模拟标签修改。`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation` 的 `陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存稳定 `resource_id` 布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 layout 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。结构化 canvas 的几何、层级、分组和资源引用以 `editor_canvas_layer` 为权威,生成器对象以 `editor_canvas_generation_dialog` 为权威;legacy canvas 才在 2 MiB 上限内从 `editor_canvas.layers_json` 兼容读取。新写入不再把素材生成输入快照作为图层布局真相保存。历史普通图层缺资源只能由 migration operator 调用 `repair_editor_canvas_resources_and_return` 定向修复:procedure 每次只处理一个尚无迁移记录的 legacy canvas,校验 owner/project、revision、canvas/project 两份 raw layout SHA-256、精确 layer/resource/sourceResourceId、同工程替换资源与 private asset_object 谱系;图片只替换引用,音频只恢复经核验的 `420x120` 项目资源行。运维入口 `npm run spacetime:editor-canvas-resources:repair` 默认 dry-run,apply 必须绑定 plan SHA-256 并在成功后自动复核 already-repaired,禁止手工 SQL 绕过事务 guard。 +- 完美像素资源:处理成功时只允许一个最终 PNG 对象对应一个新 project resource;源图已有正式 project resource 时,`source_resource_id` 指向该资源;结果尺寸与最终 PNG 一致,不写逻辑低分辨率图、诊断图或前后对比图。成功时同时创建一个同源 `editor_asset`,请求省略素材文件夹时落入默认素材文件夹;completion 因权威 dialog 的删除已先持久化而跳过画布写回时,这两类已确认资源无需回滚。 - `generation_inputs_json` 包络契约:`fields` / `references` 是图片信息读取的用户可见生成输入快照;顶层允许保存后端内部结果扩展。现有 `screenColorHex` 保存实际背景色,角色、图标图集和 UI 图集抠图派生资产使用 `mattingProvider` / `mattingModel` 保存实际成功的处理后端与模型。BgFilter 保存本次 `seg_model`,阿里云通用抠图保存 `Aliyun Matting / segment-common-image`,本地键色保存 `Genarrative Local / screen-color-keying`。同源画布 BFF 的角色、图标和 UI 请求由前端自动提交 `screenColor=auto` 与默认 `segModel=birefnet`,其中 `segModel` 是不可由用户选择的请求控制字段,不进入 `generationInputs`;`background_mode` 和 `cross_check` 只属于 api-server 到 worker 的内部 RPC。External OpenAPI 不开放 `segModel`。上述内部结果字段不写入 `fields`,普通用户(包括素材 owner)与匿名公开读取均不得取得;普通用户响应还必须省略素材顶层 `provider` 和内部处理 `model`,但保留正常用户可见 `model` 与其他合法的顶层功能字段。后台管理和服务端审计可读取原始值。过滤只作用于普通用户 / 公开响应边界,不修改素材或精选快照,因此历史数据无需迁移。 - 普通用户生成结果契约:图片、图标图集、视频、音频和角色动画的完成响应与新建画布图层均不返回或写入生成 provider;项目资源、素材、精选和 Agent 紧凑结果使用同一读取边界。真实 provider 只保留在持久化、tracking / tracing 和后台管理原始审计中。该规则针对生成供应商元数据,不改变直传票据等必须由客户端执行的存储协议字段。 - 普通用户错误契约:手动去背景和角色动作透明化的原始服务端错误可能包含 BgFilter、分割模型或 provider 细节;Owner HTTP 响应与外部任务状态必须按 job kind 返回稳定业务文案,原始错误只保留在任务记录、tracing 与后台审计。 diff --git a/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md b/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md index 1385fc039..57c2aef3c 100644 --- a/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md +++ b/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md @@ -15,7 +15,7 @@ 允许撤销的典型操作包括移动图片、移动生成结果、调整层级、组合与取消组合、删除或剪切图片、隐藏图片、锁定与解锁、翻转、修改素材类型和调整画布视图。删除、剪切和隐藏允许撤销,是因为目标只会让内容重新出现。 -添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史;素材库后端删除发生后,同时剪除撤销栈和恢复栈中所有包含关联图层的目标快照,不能让更早的画布历史复活已删除素材。 +添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、完美像素新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史;素材库后端删除发生后,同时剪除撤销栈和恢复栈中所有包含关联图层的目标快照,不能让更早的画布历史复活已删除素材。 恢复同样执行动态安全检查。移动、层级、分组、锁定、翻转和视图等不会减少内容的操作可以恢复;重新执行删除、剪切、隐藏、删除生成结果或替换当前素材时必须被阻止。 @@ -24,6 +24,7 @@ - 撤销栈和恢复栈均保存操作类型、目标 `CanvasHistorySnapshot` 和创建时间,分别最多保留 60 条。 - 新画布操作把操作前快照写入撤销栈并清空恢复栈;成功撤销把当前快照写入恢复栈,成功恢复把当前快照写回撤销栈。 - 操作类型用于生成用户提示,并对添加、上传、生成和替换等明确会移除当前结果的撤销做保护;其它操作能否应用由当前快照与目标快照的差异检查决定。 +- 已有图片完美像素化成功落入画布前记录独立 `perfect-pixel` 历史类型,中文提示使用“完美像素”。该类型与 `generate-image`、`expand-image`、`remove-background` 一样属于新增结果保护操作:撤销不得删除派生 PNG,源图继续保留也不改变这一保护语义。像素处理失败、超时或不适用时不写历史。 - 安全检查以稳定的 `layer.id` 判断当前图层是否仍存在;内容身份优先比较对象存储 key、对象标识和媒体地址,序列帧结果比较完整帧列表。`resourceId`、`sourceResourceId`、`sourceAssetId` 等内部关联 ID 的延迟回填不视为图片替换。 - 恢复历史快照时,相同 ID 的图层以当前对象为权威,只从目标快照覆盖 `x`、`y`、`zIndex`、`groupId`、`assetKind`、`hidden`、`locked`、`flipX`、`flipY`。当前图层的资源关联、内容、媒体、生成元数据、`width` / `height` / `originalWidth` / `originalHeight` 和标题必须保留,不能被异步回填前的旧快照覆盖。 - 当前生成对话框和非活动生成结果按稳定 ID 纳入内容存在性检查,避免恢复操作删除当前生成结果。相同 ID 的生成对话框只从目标快照恢复占位框 `x` / `y` 以及 active / inactive 槽位对应的 `composerOpen`,当前占位框的 `width` / `height` / `originalWidth` / `originalHeight`、当前比例与清晰度等参数、`generating` / `failed` / 完成态、提示词、参考图和任务结果继续以当前状态为准,不能被旧历史快照降级。 @@ -34,6 +35,7 @@ - 修改素材类型的撤销与恢复仍以当前图层内容为权威,但每次成功恢复类型后都要创建与恢复类型一致的正式项目 resource,再回填新 `resourceId`。同一图层的 resource 创建请求使用单调版本号,迟到的旧类型响应不得覆盖更晚的 undo / redo 结果。 - 鼠标拖动在按下时暂存操作前快照;屏幕位移达到点击阈值后才开始改变画布坐标并只提交一条历史,阈值内的指针抖动和单击都不产生位移或历史记录。 - 本地即时结果与后端项目快照结果都必须在生成图层加入画布前写入一条生成历史;生成完成后的自动适合视图不再额外压入视口历史,保证用户第一次撤销就命中生成保护。 +- 完美像素的关闭 composer 占位沿用 generation dialog 的内容存在性保护。占位删除已先持久化时,后端 completion 不得用旧 placeholder 复活占位或派生图层;回包时本地占位已删除则前端不应用完成快照或写 `perfect-pixel` 历史,已经持久化的 project resource / 账号素材仍可由资源或素材入口读取。现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。 - 顶部消息复用 `PlatformRuntimeStatusToast`,成功使用中性色,被阻止使用警告色;连续触发会替换消息并重新开始 3 秒计时。 ## 验收重点 @@ -55,3 +57,5 @@ 15. 图层移动后从素材库删除关联素材,随后撤销或恢复都不得把已删除图层重新加入画布;普通画布删除未伴随素材库删除时仍可撤销。 16. 打开“修改图片”并输入未提交提示词后,从按钮等非输入控件触发撤销不得关闭弹窗或回退当前草稿。 17. 修改素材类型后立即撤销或快速撤销再恢复,最终只允许最新类型的 resource 响应回填;刷新项目后类型与最后一次成功历史操作一致。 +18. 完美像素结果加入画布后只写一条 `perfect-pixel` 历史;第一次撤销命中保护提示,源图和派生 PNG 都不消失。 +19. 完美像素处理中删除占位后,本次完成回包不得在本地重新应用结果或写 `perfect-pixel` 历史;删除已先持久化时,后端不得复活占位或结果图层;已成功持久化的资源或账号素材允许保留。 diff --git a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md index e04e3b0b2..6c4abd51a 100644 --- a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md +++ b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md @@ -69,6 +69,21 @@ worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存 重复 completion 必须返回同一资源、layer 和 dialog 终态,不得重复插入,也不能因 dialog 暂时缺失而返回 `changed=false` 后仍把任务标记完成。任一步失败时整笔业务写回回滚,任务保留可诊断的失败或可重试状态。 +### 3.6 免费同步栅格派生完成 + +`POST /api/editor/images/pixel-art-snaps` 的完美像素化不是 external job completion:它免费、在当前 HTTP 请求内 inline 执行,不创建任务行,也没有 `job_id / worker_id / lease_token`。前端仍须先创建关闭 composer 的右侧 generation dialog,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前布局,最后把稳定源媒体引用和带非空 `dialogId` 的 `canvasCompletion` 一次提交;结构化 / legacy canvas 的完成分流继续由后端决定,前端不能直接写表或本地补造正式 layer。 + +端点级并发排队、像素读取、静态 PNG / JPEG / WebP 编码门禁、解码、输入限制、legacy 网格步长估算、CPU 并发排队、规整和 PNG 编码全部发生在持久化前。两层排队的位置不同:端点级闸在首次 IO 之前,因此队列满的 `503` 早于任何 SpacetimeDB 读取和 OSS 下载返回;CPU 排队仍在下载之后、规整之前,等待超预算返回 `504`。两者都在持久化前失败,零写入结论不变。strict 与生成风格使用同一 profile、峰值估算、单轴步长补全、walker、采样和编码;仅在横纵两轴都未检测到步长、legacy 即将进入统一网格兜底时拒绝,任一轴已检测到步长时行为和输出完全一致。任一步失败、超时或不适用时不执行最终 OSS PUT,不创建 asset object、`editor_project_resource`、`editor_asset` 或结果 layer;不得保存原图副本、逻辑低分辨率图、诊断图或前后对比图冒充结果。处理成功时只 PUT 一张最终 PNG,并至多各创建一个 project resource 和一个账号素材;源图已有正式 project resource 时,结果资源的 `source_resource_id` 指向该资源。 + +该零写入保证只覆盖首个最终 PNG PUT 前的可预判与处理阶段。进入持久化后,PNG / asset object、project resource、账号素材与 canvas completion 仍跨 OSS 和多个 SpacetimeDB procedure,沿用既有非事务顺序;后段失败可以保留此前已经确认的对象或记录,不做自动删除补偿,也不由客户端重放请求。调用方应按 `task_id / object_key / resource_id` 重新读取权威项目和素材快照后显式收口。 + +同步 completion 写画布前必须读取当前 revision 和 dialog,而不能信任请求中提交时的旧 placeholder: + +1. dialog 仍存在时,在同一当前布局上完成一个结果 layer,保留源图,并关联结果 resource; +2. dialog 的删除已先持久化时,跳过 layer / dialog 写回,不重建占位、不复活派生图层;已成功持久化的 project resource / 账号素材允许保留; +3. CAS 冲突时不得拿新 revision 原样重放旧整包;按当前项目保存冲突规则重新读取权威快照并显式收口; +4. 客户端回包时若本地 dialog 已删除,不应用完成快照或写历史;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`。请求字节可能已发出后的 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 不自动重放,先通过 GET 核对项目 / 素材快照,再由用户显式决定是否再次执行;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。 + ## 4. 存量迁移 迁移按 canvas 执行 `backfill → hash 核对 → activate`,并保持幂等: @@ -110,5 +125,6 @@ SpacetimeDB 必须先于依赖新 procedure / bindings 的 API 发布;前端 - structured 模式下 typed 列而非扩展 JSON 决定几何、层级、分组、显示 / 锁定、资源引用、`asset_kind_override` 和 dialog 状态;标签展示和类型能力判断统一按 `override ?? resource default`。修改当前图层标签与清除覆盖都保持 `resource_id` 和资源行数量不变;复制共享同一资源并复制 override,随后各副本可独立修改 override。两个客户端基于同一 revision 写入时只允许一个成功,冲突方重载后端最新快照,不换上新 revision 原样重放旧整包。细粒度 batch mutation 是取消 2 MiB 兼容入口的后续项,不冒充为本次已完成。 - worker completion 当前以读取时 revision 做 CAS,冲突时拒绝覆盖;V2 保存和保存后快照在同一 procedure 结果内返回,避免“已提交但后续 GET 失败”的不确定结果。lease-fenced 资源 / layer / dialog / job 单事务 completion 仍是后续收口项。 - structured 快照刷新后,上传参考图、生成结果、占位与 dialog 状态均可恢复;资源存在但布局写入失败时不会伪装为保存成功。 +- 完美像素处理失败 / 超时时 OSS、resource、asset 和 layer 均无新增;成功时只有一个最终 PNG、至多一个 project resource 和一个账号素材。处理中占位删除已先持久化时,完成请求不复活 dialog 或结果 layer;回包时本地占位已删除则不应用完成快照,已成功创建的资源 / 素材仍可读取;传输结果未知时客户端不自动重放 unsafe POST。 - 回滚重组结果经 schema 校验、canonical hash / 资源引用核对且不超过 2 MiB;超限或不一致时明确拒绝且 structured 快照仍可读取。 - 完成 `npm run spacetime:generate`,确认 Rust 表字段、migration、生成 bindings、HTTP DTO 与前端 `assetKindOverride` 形状一致;再运行 `npm run check:spacetime-runtime-access`、`npm run check:spacetime-schema`、相关 Rust / API / 前端定向测试、`npm run check:encoding` 和 `git diff --check`。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 93ef6b36a..25459c1a8 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -46,7 +46,7 @@ - `model`:支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。 - `aspectRatio`:按 `x:y` 展示,选项跟随模型。 - `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。 - - `style`:可选生成后处理风格;未勾选像素艺术时传 `"none"`,勾选时传 `"pixelArt"`。 + - `style`:可选生成风格,同时影响提交给 provider 的提示词和回图后的像素规整;未勾选像素艺术时传 `"none"`,勾选时传 `"pixelArt"`。 - `priceMudPoints`:按当前模型和尺寸从编辑器生成计费配置计算;`nanobanana2 1K` 为 `12`,`gpt-image-2 1K` 为 `3`、`gpt-image-2 2K` 为 `5`。前端只提交配置函数计算值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死价格。 - 模型与尺寸选项: - `nanobanana2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig`;`0.5K` 按 VectorEngine 文档传 `"512"`。 @@ -65,6 +65,7 @@ - 图标素材面板增加紧凑的 `像素艺术` 勾选项。选择保存于现有生成器快照,并可随现有请求和队列 payload 传递;不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。 - `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。 +- 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。图标链路使用「每个图标素材均为像素风格」,**不得**使用「画面为像素风格」——图集生成后要按纯色抠像,绿幕底必须保持平整,画面级像素化要求会与同一段提示词里的「纯色背景必须平整无纹理、无渐变」互相拆台;一张图内是多个彼此分离的素材,需要逐个点名,避免模型只把其中一部分做成像素块。注入发生在 `build_editor_icon_spritesheet_prompt` 返回之后,该函数签名和输出契约不变。约束句只随工程化提示词写入**原图 spritesheet** 的 `editor_project_resource` prompt 列;透明结果的 prompt 列是 `"去除纯色背景"`,自动拆分的切片是 `"自动拆分图集"`,两者都不含约束句。与普通图片和角色形象不同,本链路**响应体**的 `prompt` 字段返回的也是含约束句的工程化提示词,而不是用户输入——图标请求本身没有 `prompt` 字段(收的是 `iconDescriptions`),因此调用方(含外部 API v1)能直接看到绿幕子句、间距要求和本次新增的像素约束。以上 prompt 列写入与响应字段规则都是既有行为,与 `web/master` 一致,本次只是让被回传的模板多了一行。不新增 OSS PUT、项目资源、图集画布项或切片画布项。尚未约束各素材共用同一像素块大小(`estimate_step_size` 取全图相邻峰间距的第 30 百分位,块大小不一时步长估计会偏),等实测。 - 图标链路以已持久化的带纯色背景 provider 原图实际尺寸为基准;BgFilter 正常成功后,把 Alpha 蒙版回贴到该同尺寸平底原图,再执行像素规整。网格分析源使用平底 provider 原图,RGBA 采样源使用 Alpha 已回贴的透明图;规整结果不再经过独立的最终尺寸处理,直接上传透明 spritesheet,成功后才进入原有连通域自动拆分。 - 首版固定参数为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格颜色按 `Σ(A × RGB) / ΣA` 进行 Alpha 加权;覆盖率 `Σ(A / 255) / N >= 0.375` 且 `ΣA > 0` 时输出硬 Alpha `255`,否则输出严格 `[0,0,0,0]`。分析色数不限制最终输出色数。 - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级,随后仍可进入原有自动拆分。 @@ -93,7 +94,7 @@ - 默认提示文本会完整进入 prompt;用户输入不再被解析为素材数量。例如“各种敌人头像:骷髅 哥布林 强盗 龙 蝙蝠等”只是一段完整需求,不代表必须生成或拆出 `6` 个素材。 - 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。 - 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 -- 图标素材面板可选择 `style: "none" | "pixelArt"`;`none` 完整保持原处理路径,`pixelArt` 在 Alpha 回贴后、自动拆分前执行内存像素规整,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。 +- 图标素材面板可选择 `style: "none" | "pixelArt"`;`none` 完整保持原处理路径,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「每个图标素材均为像素风格」并在 Alpha 回贴后、自动拆分前执行内存像素规整,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。 - 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`。 - 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的全部有效连通域图标图层,图标依次命名为 `素材 N`;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。 - 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index c705be75a..9bbf89281 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -65,6 +65,7 @@ - 角色面板增加紧凑的 `像素艺术` 勾选项,请求使用可选字符串字段 `style`:未勾选传 `"none"`,勾选传 `"pixelArt"`。该选择可以随现有生成器快照和队列 payload 保存,但不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。 - `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 在 `kind="character"` 时启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。同一图片生成请求 DTO 被其它 `kind` 复用时,只有普通图片和 `character` 支持 `"pixelArt"`,其它 `kind` 收到该值也按不支持风格降级。 +- 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。角色链路使用「角色主体为像素风格」,**不得**使用「画面为像素风格」——角色生成后要按纯色抠像,绿幕底必须保持平整,同一段提示词里已写死「纯色背景必须平整无纹理、无渐变」,画面级像素化要求会与之互相拆台;且该提示词已禁止出现角色以外的场景内容,因此只需点名角色本身。注入发生在 `build_editor_character_image_prompt` 返回之后,该函数签名和输出契约不变。约束句**不会**进入角色链路的任何 `editor_project_resource`:原图 resource 的 prompt 列存的是 `role_setting`(用户原文),透明结果的 `output_prompt` 在抠图成功后被无条件覆盖为 `"去除纯色背景"`;完整提交提示词是否留存取决于 provider:`persist_editor_provider_source_image` 写原图 asset object 元数据时用的是 `actual_prompt.unwrap_or(prompt)`,provider 未回 `actualPrompt` 时才存 `submitted_prompt`(含约束句),此时排障可按 `object_key` 查;provider 回了 `actualPrompt` 就存 provider 改写后的文本,该次生成的 `submitted_prompt` 在系统内一处都不落——外部 API 审计的 `request_payload` 只记 `promptChars` 字符数,没有提示词原文。响应体返回的是用户原文,前端显示不变。以上 prompt 列写入与 asset object 元数据规则都是既有行为,与 `web/master` 逐行一致,本次未改动。角色提示词里既有的「严格基于图1的角色美术视觉规范的美术风格」与像素约束存在潜在冲突,本次未改写,等实测。 - 角色 provider 回图先按统一业务像素矩阵执行交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。归一后的带纯色背景图先持久化并作为 BgFilter 输入;BgFilter 正常成功后,把 Alpha 蒙版回贴到这张同尺寸平底原图,再执行像素规整并上传透明主图。网格分析源使用已收口到实际交付尺寸的平底原图,RGBA 采样源使用 Alpha 已回贴的透明图;软 Alpha 只参与单格覆盖率和 Alpha 加权 RGB 计算,输出 Alpha 硬化为 `0 / 255`。 - 首版参数固定为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格覆盖率 `Σ(A / 255) / N >= 0.375` 且 `ΣA > 0` 时输出 `A=255`,颜色按 `Σ(A × RGB) / ΣA` 计算;否则输出 `[0,0,0,0]`。分析色数不限制最终输出色数。 - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级。 @@ -118,7 +119,7 @@ - `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。 - 上传常规参考图后缩略图右下角显示序号。 - 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`screenColor`、`aspectRatio` 和 `imageSize`。 -- 角色面板可选择 `style: "none" | "pixelArt"`;`none` 的处理路径和产物保持不变,`pixelArt` 在 Alpha 回贴后执行内存像素规整,最终 OSS PUT、项目资源和画布图层数量不得增加。 +- 角色面板可选择 `style: "none" | "pixelArt"`;`none` 的处理路径和产物保持不变,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「角色主体为像素风格」并在 Alpha 回贴后执行内存像素规整,最终 OSS PUT、项目资源和画布图层数量不得增加。 - 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。 - 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。 diff --git a/scripts/check-module-runtime-artifact.mjs b/scripts/check-module-runtime-artifact.mjs index ad3ea0dc4..01cfec302 100644 --- a/scripts/check-module-runtime-artifact.mjs +++ b/scripts/check-module-runtime-artifact.mjs @@ -152,8 +152,15 @@ function parseArchiveObjectMembers(artifact) { longNameTable = artifact.subarray(contentStart, contentEnd); } else if (/^\/\d+$/u.test(rawName) && longNameTable) { const nameOffset = Number.parseInt(rawName.slice(1), 10); - const nameEnd = longNameTable.indexOf(0x0a, nameOffset); - const resolvedEnd = nameEnd >= 0 ? nameEnd : longNameTable.length; + // GNU archives use newline-terminated names; MSVC COFF archives use NUL. + const candidateNameEnds = [ + longNameTable.indexOf(0x00, nameOffset), + longNameTable.indexOf(0x0a, nameOffset), + ].filter((nameEnd) => nameEnd >= 0); + const resolvedEnd = + candidateNameEnds.length > 0 + ? Math.min(...candidateNameEnds) + : longNameTable.length; memberName = longNameTable .subarray(nameOffset, resolvedEnd) .toString('utf8') diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index bb788cedc..a2d70713f 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1963,6 +1963,147 @@ mod tests { } } + #[tokio::test] + async fn editor_pixel_art_snap_requires_bearer_auth() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let request_body = serde_json::json!({ + "sourceImageSrc": "editor-resource-source", + "projectId": "proj-source", + "canvasCompletion": { + "dialogId": "dialog-pixel-art", + "title": "完美像素", + "placeholder": { + "x": 0, + "y": 0, + "width": 128, + "height": 128, + "originalWidth": 128, + "originalHeight": 128 + } + } + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/images/pixel-art-snaps") + .header("content-type", "application/json") + .body(Body::from(request_body.to_string())) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn editor_pixel_art_snap_rejects_inline_source_before_processing() { + let state = AppState::new(AppConfig { + external_generation_mode: ExternalGenerationMode::Queue, + ..AppConfig::default() + }) + .expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138230", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_body"); + let app = build_router(state); + let request_body = serde_json::json!({ + "sourceImageSrc": "data:image/png;base64,AAAA", + "projectId": "proj-source", + "sourceResourceId": "editor-resource-source", + "assetKind": "character", + "generationInputs": { "fields": [], "references": [] }, + "canvasCompletion": { + "dialogId": "dialog-pixel-art", + "title": "完美像素", + "placeholder": { + "x": 0, + "y": 0, + "width": 128, + "height": 128, + "originalWidth": 128, + "originalHeight": 128 + } + } + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/images/pixel-art-snaps") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(request_body.to_string())) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let body_text = String::from_utf8_lossy(&body); + assert!( + body_text.contains("先上传 OSS"), + "handler should reject inline pixel-art sources: {body_text}" + ); + } + + #[tokio::test] + async fn editor_pixel_art_snap_requires_dialog_id_before_project_or_media_work() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138231", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_dialog"); + let app = build_router(state); + let request_body = serde_json::json!({ + "sourceImageSrc": "editor-resource-source", + "projectId": "proj-does-not-exist", + "canvasCompletion": { + "title": "完美像素", + "placeholder": { + "x": 0, + "y": 0, + "width": 128, + "height": 128, + "originalWidth": 128, + "originalHeight": 128 + } + } + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/images/pixel-art-snaps") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(request_body.to_string())) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let body_text = String::from_utf8_lossy(&body); + assert!( + body_text.contains("canvasCompletion.dialogId"), + "handler should reject missing dialog ids before project lookup: {body_text}" + ); + } + #[tokio::test] async fn editor_generation_json_validation_preserves_unsupported_media_type() { let state = AppState::new(AppConfig { diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index cd88a0e89..1c9426b5b 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2,18 +2,21 @@ use std::{ borrow::Cow, collections::{BTreeMap, HashSet}, io::Cursor, - sync::{Arc, LazyLock}, + sync::{ + Arc, LazyLock, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, time::{Duration, Instant}, }; use axum::{ Json, extract::{Extension, Path, Query, State, rejection::JsonRejection}, - http::StatusCode, + http::{HeaderValue, StatusCode}, }; use module_assets::{ - AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input, - generate_asset_object_id, + AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectUpsertInput, + build_asset_object_upsert_input, generate_asset_object_id, }; use platform_image::{ DownloadedImage, @@ -35,6 +38,8 @@ use shared_contracts::assets::{ }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::editor_project::{ + EditorPixelArtCanvasCompletionRecordInput, EditorPixelArtCanvasPlaceholderRecordInput, + EditorPixelArtResultPersistRecordInput, EditorPixelArtResultPreflightRecordInput, EditorSpritesheetSliceBatchPersistRecordInput, EditorSpritesheetSlicePersistItemRecordInput, }; use spacetime_client::{ @@ -76,7 +81,10 @@ use crate::{ }, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, - adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, + adapter::{ + GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput, + GeneratedImageAssetPreparedPut, + }, decode_generated_image_asset_data_url, normalize_generated_image_asset_mime, }, http_error::AppError, @@ -142,12 +150,49 @@ const EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE: &str = "unsupported-imag pub(crate) const EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE: &str = "multiple-generation-warnings"; const EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT: f64 = 0.05; const EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY: usize = 2; +/// 中文注释:inline 完美像素的端点级并发闸。它是仓库里唯一「用户主动触发 + 同步执行 + +/// 下载大图 + 吃 CPU」且不经生成队列的路径——其余像素规整入口都由 job + worker 承担准入。 +/// 闸此前只有 CPU 许可那一道,而它设在下载之后:请求先把最多 32 MiB 读进内存、先打完 +/// 几轮全账号 SpacetimeDB 扫描,才被拦下排队,等于闸在资源已被消耗之后才检查。 +/// 客户端只发几百字节 JSON 就能让服务端拉取 32 MiB,放大比约 64000 : 1,且本操作免费 +/// (generation_cost_mud_points = 0)、无 per-user 配额。 +/// +/// 这道闸覆盖从第一次 IO 到 handler 结束的全过程,一个数字同时封住并发 SpacetimeDB +/// 扫描数、并发源图缓冲数与并发 OSS PUT 数。取 4 而不是等于 CPU 槽的 2:2 会让下载完全 +/// 串在 snap 后面,4 才能让两个请求下载的同时另两个在算,把下载延迟藏进 CPU 时间里。 +const EDITOR_PIXEL_ART_SNAP_MAX_CONCURRENCY: usize = 4; +/// 中文注释:等待队列保险丝,对齐 BgFilter 的 Q。部署配置里全局准入是 512,小于这个值, +/// 所以正常部署下它打不到;它兜的是 max_concurrent_requests 未配置(代码默认 None, +/// 即无全局上限)时的连接风暴。它只防雪崩,不做流量整形。 +const EDITOR_PIXEL_ART_SNAP_MAX_QUEUE_DEPTH: usize = 2048; const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); +/// 中文注释:持久化阶段的独立预算。它与上面的处理预算相加就是服务端最坏合法时长,必须小于 +/// 客户端 `snapEditorImageToPixelArt` 的 120 秒超时——否则客户端会在服务端仍在合法工作时 +/// 先 abort,对账采样到仍在途的操作(占位还在、素材库还空),用户照提示核对却什么也看不到, +/// 重试就造出孤儿对象。30 + 60 = 90,余 30 秒给网络往返。 +/// 独立起算而不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,因下载慢而被砍预算、 +/// 中途放弃只会留下孤儿对象。 +const EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION: Duration = Duration::from_secs(60); +/// 中文注释:完美像素的 OSS PUT/HEAD 位于数据库事务外;随后 asset object、project +/// resource、editor asset 与可选 canvas completion 由单个 SpacetimeDB procedure 原子提交。 +/// HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此第一次 OSS PUT 之后仍属于 +/// 未知结果边界。这个 detail 字段只在该边界之后置位,客户端据此先读权威快照对账。 +pub(crate) const EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL: &str = "resultPersistenceStarted"; +const EDITOR_PIXEL_ART_SNAP_ASSET_KIND: &str = "editor_pixel_art_snap"; +const EDITOR_PIXEL_ART_SNAP_MODEL: &str = "Perfect Pixel"; +const EDITOR_PIXEL_ART_SNAP_PROVIDER: &str = "Genarrative"; +const EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION: &str = "perfect-pixel-v1"; static EDITOR_PIXEL_ART_CPU_LIMITER: LazyLock> = LazyLock::new(|| { Arc::new(tokio::sync::Semaphore::new( EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY, )) }); +static EDITOR_PIXEL_ART_SNAP_LIMITER: LazyLock> = LazyLock::new(|| { + Arc::new(tokio::sync::Semaphore::new( + EDITOR_PIXEL_ART_SNAP_MAX_CONCURRENCY, + )) +}); +static EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH: AtomicUsize = AtomicUsize::new(0); static EDITOR_ICON_SPRITESHEET_CPU_LIMITER: LazyLock> = LazyLock::new(|| { Arc::new(tokio::sync::Semaphore::new( @@ -332,6 +377,19 @@ pub struct EditorBackgroundRemovalRequest { pub(crate) canvas_completion: Option, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorPixelArtSnapRequest { + pub(crate) source_image_src: String, + pub(crate) project_id: String, + pub(crate) source_resource_id: Option, + pub(crate) asset_kind: Option, + pub(crate) generation_inputs: Option, + pub(crate) asset_folder_id: Option, + pub(crate) asset_label: Option, + pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetGenerationRequest { @@ -813,6 +871,23 @@ pub struct EditorBackgroundRemovalResponse { project: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorPixelArtSnapResponse { + image_src: String, + object_key: String, + asset_object_id: String, + width: u32, + height: u32, + source_type: &'static str, + task_id: String, + elapsed_ms: u64, + provider: &'static str, + resource: EditorProjectResourcePayload, + asset: EditorAssetPayload, + project: Option, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetIconResponse { @@ -1705,6 +1780,13 @@ pub(crate) async fn generate_editor_image_for_owner( let pixel_art_supported = matches!(normalized_kind, None | Some("") | Some("character")); let (image_style, mut generation_warning) = normalize_editor_image_generation_style(payload.style.as_deref(), pixel_art_supported); + // 中文注释:角色形象带绿幕抠像底色,只像素化角色本身;普通图片没有抠像底色,整幅画面像素化。 + // ui-design 等 kind 走不到 PixelArt(pixel_art_supported 已挡掉),落到 Image 分支无副作用。 + let pixel_art_prompt_scope = if is_character_generation { + EditorPixelArtPromptScope::Character + } else { + EditorPixelArtPromptScope::Image + }; // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要、但随后会被 payload 消费掉的输入。 let requested_screen_color = payload.screen_color.clone(); @@ -1850,16 +1932,20 @@ pub(crate) async fn generate_editor_image_for_owner( let screen_color = screen_background_decision .as_ref() .map(|decision| decision.color); - let submitted_prompt = if is_character_generation { - build_editor_character_image_prompt( - role_setting.as_str(), - screen_color.expect("character generation should have screen color"), - ) - } else if is_ui_design_generation { - build_editor_ui_design_prompt(role_setting.as_str(), ui_has_references) - } else { - role_setting.clone() - }; + let submitted_prompt = apply_editor_pixel_art_style_prompt( + if is_character_generation { + build_editor_character_image_prompt( + role_setting.as_str(), + screen_color.expect("character generation should have screen color"), + ) + } else if is_ui_design_generation { + build_editor_ui_design_prompt(role_setting.as_str(), ui_has_references) + } else { + role_setting.clone() + }, + image_style, + pixel_art_prompt_scope, + ); let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { create_openai_nanobanana_generate_content( &http_client, @@ -3257,6 +3343,93 @@ fn take_arc_downloaded_image(image: Arc) -> DownloadedOpe Arc::try_unwrap(image).unwrap_or_else(|image| image.as_ref().clone()) } +// 中文注释:只在 depth < max_depth 时递增,用 CAS 而不是「先读后加」——两个线程同时读到 +// max_depth - 1 各自加一就会越界。抽成自由函数是为了能直接单测边界与并发行为。 +fn try_enter_bounded_queue(depth: &AtomicUsize, max_depth: usize) -> bool { + depth + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + (current < max_depth).then_some(current + 1) + }) + .is_ok() +} + +/// 中文注释:递减必须放在 Drop 里。等待中的 future 随时可能被丢弃(客户端断连、超时触发、 +/// 上层取消),若把递减写在正常返回路径上,计数就会只增不减,最终队列永久「满」、接口 +/// 彻底不可用——这是本改动里唯一一处写错会造成永久性故障的地方。 +struct EditorPixelArtSnapQueueGuard; + +impl EditorPixelArtSnapQueueGuard { + fn try_enter() -> Option { + try_enter_bounded_queue( + &EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH, + EDITOR_PIXEL_ART_SNAP_MAX_QUEUE_DEPTH, + ) + .then_some(Self) + } +} + +impl Drop for EditorPixelArtSnapQueueGuard { + fn drop(&mut self) { + EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.fetch_sub(1, Ordering::AcqRel); + } +} + +// 中文注释:端点级并发闸,必须在第一次 IO 之前取得,许可持有到 handler 结束。等待时间与 +// 后续处理共用同一份 processing_deadline,所以排队不会让请求重新获得完整预算。 +// 中文注释:三条竞争路径的错误各自抽成构造函数。它们要靠全局信号量被打满或队列计数到顶 +// 才走得到,而那两个都是进程级 static——在测试里把它们填满会让并行跑的其他用例连带失败, +// 正是本文件刚修掉的那类竞态。抽出来之后状态码、文案和 retry-after 可以直接断言, +// 「哪条路径用哪个构造函数」则由 snap_editor_image_to_pixel_art 的顺序守卫钉住。 +fn editor_pixel_art_snap_queue_full_error() -> AppError { + editor_pixel_art_snap_failure( + StatusCode::SERVICE_UNAVAILABLE, + "完美像素排队已满,请稍后重试。", + ) + .with_header("retry-after", HeaderValue::from_static("1")) +} + +fn editor_pixel_art_snap_limiter_unavailable_error(error: impl std::fmt::Display) -> AppError { + editor_pixel_art_snap_failure( + StatusCode::SERVICE_UNAVAILABLE, + format!("完美像素并发门限不可用:{error}"), + ) +} + +fn editor_pixel_art_snap_wait_timeout_error() -> AppError { + editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素等待处理槽位时预算已耗尽。", + ) +} + +async fn acquire_editor_pixel_art_snap_permit( + processing_deadline: Instant, +) -> Result { + // 中文注释:这句预检不能省。timeout_at 会先 poll 一次内层 future,许可空闲时 + // acquire_owned 立刻就绪,于是预算已耗尽的请求照样拿到许可,白占一个名额再去打 + // 几轮全账号扫描,直到下载那步才失败。既有 CPU 许可的同名预检也是为此存在。 + if Instant::now() >= processing_deadline { + return Err(editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素处理预算已耗尽。", + )); + } + let queue_guard = EditorPixelArtSnapQueueGuard::try_enter() + .ok_or_else(editor_pixel_art_snap_queue_full_error)?; + let acquired = tokio::time::timeout_at( + tokio::time::Instant::from_std(processing_deadline), + Arc::clone(&*EDITOR_PIXEL_ART_SNAP_LIMITER).acquire_owned(), + ) + .await; + // 中文注释:排队阶段到此结束,先让出队列位再判定结果,避免持有许可期间还占着队列名额。 + drop(queue_guard); + match acquired { + Ok(Ok(permit)) => Ok(permit), + Ok(Err(error)) => Err(editor_pixel_art_snap_limiter_unavailable_error(error)), + Err(_) => Err(editor_pixel_art_snap_wait_timeout_error()), + } +} + async fn acquire_editor_pixel_art_cpu_permit( processing_deadline: Instant, ) -> Result { @@ -3275,51 +3448,306 @@ async fn acquire_editor_pixel_art_cpu_permit( } } -async fn snap_editor_pixel_art_or_original( - rgba_source: DownloadedOpenAiImage, +fn editor_pixel_art_snap_failure(status: StatusCode, message: impl Into) -> AppError { + AppError::from_status(status).with_details(json!({ + "provider": "pixel-art-snapper", + "message": message.into(), + })) +} + +fn map_editor_pixel_art_snapper_error(error: platform_image::PixelArtSnapError) -> AppError { + let status = match &error { + platform_image::PixelArtSnapError::InvalidInput(_) + | platform_image::PixelArtSnapError::Decode { .. } => StatusCode::BAD_REQUEST, + platform_image::PixelArtSnapError::GridNotDetected => StatusCode::UNPROCESSABLE_ENTITY, + platform_image::PixelArtSnapError::DeadlineExceeded { .. } => StatusCode::GATEWAY_TIMEOUT, + platform_image::PixelArtSnapError::Encode(_) + | platform_image::PixelArtSnapError::Processing(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + editor_pixel_art_snap_failure(status, error.to_string()) +} + +fn validate_editor_pixel_art_static_asset_kind(asset_kind: Option<&str>) -> Result<(), AppError> { + let Some(asset_kind) = asset_kind.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(()); + }; + let normalized = asset_kind.to_ascii_lowercase(); + let is_non_static = normalized.contains("video") + || normalized.contains("audio") + || normalized.contains("animation") + || normalized.contains("image-sequence") + || matches!(normalized.as_str(), "sound-effect" | "background-music"); + if !is_non_static { + return Ok(()); + } + Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "assetKind", + "assetKind": asset_kind, + "message": "完美像素只支持静态图片素材。", + })), + ) +} + +fn resolve_editor_pixel_art_snap_asset_kind( + requested_asset_kind: Option<&str>, + source_asset_kind: Option>, + discovered_source_asset_kinds: &[String], + storage_asset_kinds: &[String], +) -> Result, AppError> { + let requested_asset_kind = requested_asset_kind + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let preferred_source_asset_kind = source_asset_kind + .flatten() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let discovered_source_asset_kinds = discovered_source_asset_kinds + .iter() + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + let storage_asset_kinds = storage_asset_kinds + .iter() + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>(); + for authoritative_asset_kind in preferred_source_asset_kind + .iter() + .chain(discovered_source_asset_kinds.iter()) + { + // 中文注释:同一个 objectKey 可能同时留在项目资源与素材库。请求字段即使 + // 省略,也不能掩盖任一语义权威记录里的动画/音视频类型。 + validate_editor_pixel_art_static_asset_kind(Some(authoritative_asset_kind.as_str()))?; + } + for storage_asset_kind in storage_asset_kinds { + // 中文注释:asset_object.asset_kind 使用存储 taxonomy(例如 + // editor_generation_reference_image),只承担非静态媒体门禁,不能覆盖项目 + // 资源/素材库使用的语义 taxonomy,也不能拿来和请求 assetKind 做精确比较。 + validate_editor_pixel_art_static_asset_kind(Some(storage_asset_kind))?; + } + let authoritative_asset_kind = + preferred_source_asset_kind.or_else(|| discovered_source_asset_kinds.into_iter().next()); + if authoritative_asset_kind.is_some() + && requested_asset_kind.is_some() + && requested_asset_kind != authoritative_asset_kind + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "assetKind", + "requestedAssetKind": requested_asset_kind, + "sourceAssetKind": authoritative_asset_kind, + "message": "assetKind 与来源素材权威类型不一致。", + })), + ); + } + let asset_kind = authoritative_asset_kind.or(requested_asset_kind); + validate_editor_pixel_art_static_asset_kind(asset_kind.as_deref())?; + Ok(asset_kind) +} + +fn validate_editor_pixel_art_static_raster(image: &DownloadedOpenAiImage) -> Result<(), AppError> { + validate_editor_pixel_art_static_raster_bytes(image.bytes.as_slice()).map_err(|message| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "sourceImageSrc", + "message": message, + })) + }) +} + +fn validate_editor_pixel_art_static_raster_bytes(bytes: &[u8]) -> Result<(), &'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return validate_editor_pixel_art_static_png(bytes); + } + if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + return Ok(()); + } + if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" { + return validate_editor_pixel_art_static_webp(bytes); + } + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Err("完美像素不支持 GIF;请先转换为静态 PNG、JPEG 或 WebP。"); + } + Err("完美像素只支持静态 PNG、JPEG 或 WebP 图片。") +} + +fn validate_editor_pixel_art_static_png(bytes: &[u8]) -> Result<(), &'static str> { + let mut offset = 8usize; + let mut saw_iend = false; + while offset < bytes.len() { + let header_end = offset + .checked_add(8) + .filter(|end| *end <= bytes.len()) + .ok_or("PNG 文件结构不完整。")?; + let length = u32::from_be_bytes( + bytes[offset..offset + 4] + .try_into() + .map_err(|_| "PNG 文件结构不完整。")?, + ) as usize; + let chunk_type = &bytes[offset + 4..header_end]; + let chunk_end = header_end + .checked_add(length) + .and_then(|end| end.checked_add(4)) + .filter(|end| *end <= bytes.len()) + .ok_or("PNG 文件结构不完整。")?; + if chunk_type == b"acTL" { + return Err("完美像素不支持 APNG 动图;请先转换为静态 PNG。"); + } + offset = chunk_end; + if chunk_type == b"IEND" { + saw_iend = true; + break; + } + } + if !saw_iend { + return Err("PNG 文件结构不完整。"); + } + Ok(()) +} + +fn validate_editor_pixel_art_static_webp(bytes: &[u8]) -> Result<(), &'static str> { + let riff_size = u32::from_le_bytes( + bytes[4..8] + .try_into() + .map_err(|_| "WebP 文件结构不完整。")?, + ) as usize; + let riff_end = riff_size + .checked_add(8) + .filter(|end| *end <= bytes.len()) + .ok_or("WebP 文件结构不完整。")?; + let mut offset = 12usize; + let mut saw_image_chunk = false; + while offset < riff_end { + let header_end = offset + .checked_add(8) + .filter(|end| *end <= riff_end) + .ok_or("WebP 文件结构不完整。")?; + let chunk_type = &bytes[offset..offset + 4]; + let length = u32::from_le_bytes( + bytes[offset + 4..header_end] + .try_into() + .map_err(|_| "WebP 文件结构不完整。")?, + ) as usize; + let data_end = header_end + .checked_add(length) + .filter(|end| *end <= riff_end) + .ok_or("WebP 文件结构不完整。")?; + if chunk_type == b"ANIM" || chunk_type == b"ANMF" { + return Err("完美像素不支持动画 WebP;请先转换为静态 WebP。"); + } + if chunk_type == b"VP8X" + && bytes + .get(header_end) + .is_some_and(|feature_flags| feature_flags & 0x02 != 0) + { + return Err("完美像素不支持动画 WebP;请先转换为静态 WebP。"); + } + if chunk_type == b"VP8 " || chunk_type == b"VP8L" { + saw_image_chunk = true; + } + offset = data_end + .checked_add(length % 2) + .filter(|end| *end <= riff_end) + .ok_or("WebP 文件结构不完整。")?; + } + if !saw_image_chunk { + return Err("WebP 文件结构不完整。"); + } + Ok(()) +} + +async fn snap_editor_pixel_art_strict( + rgba_source: Arc, request_deadline: Option, -) -> (DownloadedOpenAiImage, Option) { +) -> Result { + snap_editor_pixel_art_with_grid_policy(rgba_source, request_deadline, true).await +} + +async fn snap_editor_pixel_art_with_grid_policy( + rgba_source: Arc, + request_deadline: Option, + require_detected_grid: bool, +) -> Result { let processing_deadline = resolve_editor_pixel_art_processing_deadline(Instant::now(), request_deadline); if Instant::now() >= processing_deadline { - return ( - rgba_source, - Some("像素规整处理预算已耗尽,已保留原始生成结果。".to_string()), - ); + return Err(editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "像素规整处理预算已耗尽。", + )); } - let permit = match acquire_editor_pixel_art_cpu_permit(processing_deadline).await { - Ok(permit) => permit, - Err(error) => return (rgba_source, Some(error)), - }; + let permit = acquire_editor_pixel_art_cpu_permit(processing_deadline) + .await + .map_err(|message| { + let status = if message.contains("预算已耗尽") { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + editor_pixel_art_snap_failure(status, message.replace(",已保留原始生成结果。", "。")) + })?; - let rgba_source = Arc::new(rgba_source); let worker_rgba_source = Arc::clone(&rgba_source); let worker = tokio::task::spawn_blocking(move || { // 中文注释:permit 必须由 blocking 闭包持有,而不是只保护 spawn; // 即使上层 future 因 worker deadline 被取消,仍会限制尚未结束的 CPU 任务数量。 let _permit = permit; - platform_image::snap_pixel_art_with_deadline( - worker_rgba_source.as_ref(), - worker_rgba_source.as_ref(), - Some(processing_deadline), - ) + if require_detected_grid { + platform_image::snap_pixel_art_strict_with_deadline( + worker_rgba_source.as_ref(), + worker_rgba_source.as_ref(), + Some(processing_deadline), + ) + } else { + platform_image::snap_pixel_art_with_deadline( + worker_rgba_source.as_ref(), + worker_rgba_source.as_ref(), + Some(processing_deadline), + ) + } }); let result = tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker).await; match result { - Ok(Ok(Ok(image))) => (image, None), - Ok(Ok(Err(error))) => ( - take_arc_downloaded_image(rgba_source), - Some(error.to_string()), - ), - Ok(Err(error)) => ( - take_arc_downloaded_image(rgba_source), - Some(format!("像素规整工作线程异常:{error}")), - ), - Err(_) => ( - take_arc_downloaded_image(rgba_source), - Some("像素规整处理超时,已保留原始生成结果。".to_string()), - ), + Ok(Ok(Ok(image))) => Ok(image), + Ok(Ok(Err(error))) => Err(map_editor_pixel_art_snapper_error(error)), + Ok(Err(error)) => Err(editor_pixel_art_snap_failure( + StatusCode::INTERNAL_SERVER_ERROR, + format!("像素规整工作线程异常:{error}"), + )), + Err(_) => Err(editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "像素规整处理超时。", + )), + } +} + +async fn snap_editor_pixel_art_or_original( + rgba_source: DownloadedOpenAiImage, + request_deadline: Option, +) -> (DownloadedOpenAiImage, Option) { + let rgba_source = Arc::new(rgba_source); + match snap_editor_pixel_art_with_grid_policy(Arc::clone(&rgba_source), request_deadline, false) + .await + { + Ok(image) => (image, None), + Err(error) => { + let reason = error.body_text(); + let reason = + reason.trim_end_matches(|character| matches!(character, '。' | '!' | '!')); + ( + take_arc_downloaded_image(rgba_source), + Some(format!("{reason},已保留原始生成结果。")), + ) + } } } @@ -3784,7 +4212,7 @@ pub(crate) async fn edit_editor_image_for_owner( &settings, generation_options.model, prompt.as_str(), - Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"), + Some("文字、水印、边框、按钮、UI 控件、变形主体"), generation_options.aspect_ratio, provider_size.as_str(), reference_images.as_slice(), @@ -3804,7 +4232,7 @@ pub(crate) async fn edit_editor_image_for_owner( &settings, generation_options.model, prompt.as_str(), - Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"), + Some("文字、水印、边框、按钮、UI 控件、变形主体"), provider_size.as_str(), 1, reference_images.as_slice(), @@ -4114,6 +4542,861 @@ struct EditorBackgroundRemovalImage { height: u32, } +fn validate_editor_pixel_art_snap_canvas_completion( + completion: &EditorCanvasGenerationCompletionRequest, +) -> Result<(), AppError> { + let Some(dialog_id) = normalized_canvas_completion_dialog_id(completion) else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.dialogId", + "message": "完美像素必须关联有效的画布生成占位。", + })), + ); + }; + if dialog_id.contains('\0') { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.dialogId", + "message": "完美像素画布生成占位 ID 不得包含 NUL。", + })), + ); + } + if completion.title.trim().is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.title", + "message": "完美像素画布结果标题不能为空。", + })), + ); + } + let placeholder = &completion.placeholder; + if !placeholder.x.is_finite() + || !placeholder.y.is_finite() + || !placeholder.width.is_finite() + || !placeholder.height.is_finite() + || !placeholder.original_width.is_finite() + || !placeholder.original_height.is_finite() + || placeholder.width <= 0.0 + || placeholder.height <= 0.0 + || placeholder.original_width <= 0.0 + || placeholder.original_height <= 0.0 + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.placeholder", + "message": "完美像素画布占位的坐标与尺寸必须有效。", + })), + ); + } + Ok(()) +} + +fn validate_editor_pixel_art_snap_placeholder_exists( + layers: &Value, + resources: &[EditorProjectResourcePayload], + owner_user_id: &str, + project_id: &str, + completion: &EditorCanvasGenerationCompletionRequest, +) -> Result<(), AppError> { + let dialog_id = normalized_canvas_completion_dialog_id(completion).ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "完美像素必须关联有效的画布生成占位。", + ) + })?; + if layers.as_array().is_some_and(|items| { + items + .iter() + .any(|item| generation_dialog_item_matches(item, dialog_id.as_str())) + }) { + return Ok(()); + } + // 中文注释:首个事务若以 DialogMissing 成功、但 HTTP 响应丢失,同 operation 的稳定 + // project resource 已存在,而占位按定义仍然不存在。该形状必须允许继续走到原子 procedure + // 的 exact compare-and-return;否则幂等重放会被这个处理前门禁反向拦成 409。 + let expected_task_id = format!("pixel-art-snap-{dialog_id}"); + let expected_resource_id = format!( + "{EDITOR_RESOURCE_ID_PREFIX}{}", + editor_pixel_art_stable_record_suffix( + owner_user_id, + project_id, + dialog_id.as_str(), + "project-resource", + ) + ); + if resources.iter().any(|resource| { + resource.resource_id == expected_resource_id + && resource.owner_user_id == owner_user_id + && resource.project_id == project_id + && resource.task_id.as_deref() == Some(expected_task_id.as_str()) + }) { + return Ok(()); + } + Err( + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.dialogId", + "dialogId": dialog_id, + "message": "完美像素画布占位不存在或尚未保存,请重试。", + })), + ) +} + +#[derive(Debug)] +struct EditorPixelArtSourceResolution { + object_key: String, + asset_kind: Option, +} + +fn push_editor_pixel_art_source_asset_kind( + asset_kinds: &mut Vec, + asset_kind: Option<&str>, +) { + let Some(asset_kind) = asset_kind + .map(str::trim) + .filter(|asset_kind| !asset_kind.is_empty()) + else { + return; + }; + if !asset_kinds.iter().any(|candidate| candidate == asset_kind) { + asset_kinds.push(asset_kind.to_string()); + } +} + +// 中文注释:source_resource 来自 owner-scoped 的 get_editor_project,已经完成鉴权, +// 因此可以直接用它的 objectKey,不必再按注册 ID 做两轮全账号项目与素材库扫描。做法 +// 对齐图集拆分:用对资源的显式归属断言替代扫描,而不是省掉校验。 +// 前提是 sourceImageSrc 能在不发 RPC 的前提下确认指向同一张图——它要么本身就是 +// objectKey,要么就是这个 resourceId。否则落回完整解析路径。 +fn resolve_editor_pixel_art_source_without_lookup( + owner_user_id: &str, + project_id: &str, + source: &str, + source_resource: &EditorProjectResourcePayload, +) -> Result, AppError> { + if source_resource.owner_user_id != owner_user_id || source_resource.project_id != project_id { + return Err(editor_pixel_art_snap_failure( + StatusCode::FORBIDDEN, + "来源项目资源不属于当前账号或当前项目。", + )); + } + let source_resource_object_key = normalize_editor_record_object_key( + source_resource.object_key.as_deref(), + source_resource.image_src.as_str(), + ) + .ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "来源项目资源没有可用的稳定图片引用。", + ) + })?; + let source = source.trim(); + if source == source_resource.resource_id.trim() { + return Ok(Some(source_resource_object_key)); + } + match normalize_editor_reference_object_key(source) { + // 中文注释:sourceImageSrc 已是 objectKey,直接比对即可判定一致性;不一致必须 + // 报错而不是落回慢路径,否则「两个字段指向不同图片」会被慢路径静默接受。 + Ok(source_object_key) => { + if source_object_key != source_resource_object_key { + return Err(editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "sourceImageSrc 与 sourceResourceId 指向不同图片。", + )); + } + Ok(Some(source_resource_object_key)) + } + // sourceImageSrc 是别的注册 ID,只能走完整解析。 + Err(_) => Ok(None), + } +} + +async fn resolve_editor_pixel_art_source_for_owner( + state: &AppState, + owner_user_id: &str, + project_id: &str, + source: &str, + project: &EditorProjectPayload, + source_resource: Option<&EditorProjectResourcePayload>, + requested_asset_kind: Option<&str>, +) -> Result { + let resolved_without_lookup = match source_resource { + Some(source_resource) => resolve_editor_pixel_art_source_without_lookup( + owner_user_id, + project_id, + source, + source_resource, + )?, + None => None, + }; + // 中文注释:没有已鉴权 source_resource 时,注册 ID 解析、归属校验和下面的跨记录 + // asset_kind 扫描需要的是同一份全账号快照。此前这三件事各自取数—— + // `resolve_editor_reference_object_key_for_owner` 内部两个子函数各扫一轮,本函数再扫 + // 第三轮——同一份数据被 `list_editor_projects` + `get_editor_asset_library` 拉了 6 次, + // 全部在 30 秒预算和端点准入名额之内顺序执行。参照抠图入口 + // `resolve_editor_background_removal_source` 的做法:扫一次,后续解析全部交给 + // `_from_records` 纯函数在内存里完成。老包装 `resolve_editor_reference_object_key_for_owner` + // 保持不动,它服务的是没有任何上下文的调用方。 + let owner_records = if resolved_without_lookup.is_some() { + None + } else { + let projects = state + .spacetime_client() + .list_editor_projects(owner_user_id.to_string()) + .await + .map_err(map_editor_project_error)?; + let library = state + .spacetime_client() + .get_editor_asset_library(owner_user_id.to_string(), current_utc_micros()) + .await + .map_err(map_editor_project_error)?; + Some((projects, library)) + }; + let object_key = match resolved_without_lookup.clone() { + Some(object_key) => object_key, + None => { + let (projects, library) = owner_records + .as_ref() + .expect("owner records are loaded whenever the lookup-free path is unavailable"); + let object_key = match normalize_editor_reference_object_key(source) { + Ok(object_key) => object_key, + Err(error) => find_editor_reference_object_key_by_registered_id_from_records( + projects.as_slice(), + library.assets.as_slice(), + source.trim(), + ) + .ok_or(error)?, + }; + // 中文注释:命中 owner 已登记记录就不再发 RPC;只有两份记录都查不到时才回落到 + // asset object 点查兜底,与抠图入口的短路一致。 + if !editor_reference_object_key_is_registered_for_owner( + projects.as_slice(), + library.assets.as_slice(), + object_key.as_str(), + ) { + ensure_editor_reference_asset_object_owned( + state, + owner_user_id, + object_key.as_str(), + ) + .await?; + } + object_key + } + }; + if resolved_without_lookup.is_none() + && let Some(source_resource) = source_resource + { + let source_resource_object_key = normalize_editor_record_object_key( + source_resource.object_key.as_deref(), + source_resource.image_src.as_str(), + ) + .ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "来源项目资源没有可用的稳定图片引用。", + ) + })?; + if source_resource_object_key != object_key { + return Err(editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "sourceImageSrc 与 sourceResourceId 指向不同图片。", + )); + } + } + + let mut discovered_asset_kinds = Vec::new(); + let mut storage_asset_kinds = Vec::new(); + for resource in project.resources.iter().filter(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + object_key.as_str(), + ) + }) { + push_editor_pixel_art_source_asset_kind( + &mut discovered_asset_kinds, + resource.asset_kind.as_deref(), + ); + } + // 中文注释:跨记录扫描只在没有已鉴权 source_resource 时才需要。它的作用是防止同一 + // objectKey 在别处登记为动画/音视频时被静态请求掩盖;有 source_resource 时该语义 + // 权威已经确定,当前项目内的同键记录仍在上面按内存扫过。字节级门禁 + // (validate_editor_pixel_art_static_raster)在下载后照常执行,不受此影响。 + if let Some((projects, library)) = owner_records.as_ref() { + for resource in projects + .iter() + .flat_map(|project| project.resources.iter()) + .filter(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + object_key.as_str(), + ) + }) + { + push_editor_pixel_art_source_asset_kind( + &mut discovered_asset_kinds, + resource.asset_kind.as_deref(), + ); + } + for asset in library.assets.iter().filter(|asset| { + editor_record_object_key_matches( + asset.object_key.as_deref(), + asset.image_src.as_str(), + object_key.as_str(), + ) + }) { + push_editor_pixel_art_source_asset_kind( + &mut discovered_asset_kinds, + asset.asset_kind.as_deref(), + ); + } + } + // 中文注释:这一处是按 (bucket, objectKey) 的点查而不是全账号扫描,成本与账号规模 + // 无关,两条路径都保留——它承担存储 taxonomy 的非静态门禁。 + if let Some(oss_client) = state.oss_client() + && let Some(asset_object) = state + .spacetime_client() + .get_asset_object_by_location(module_assets::AssetObjectLocationInput { + bucket: oss_client.config_bucket().to_string(), + object_key: object_key.clone(), + }) + .await + .map_err(map_editor_project_error)? + { + validate_editor_reference_asset_object( + &asset_object, + owner_user_id, + state, + object_key.as_str(), + )?; + push_editor_pixel_art_source_asset_kind( + &mut storage_asset_kinds, + Some(asset_object.asset_kind.as_str()), + ); + } + let asset_kind = resolve_editor_pixel_art_snap_asset_kind( + requested_asset_kind, + source_resource.map(|resource| resource.asset_kind.as_deref()), + discovered_asset_kinds.as_slice(), + storage_asset_kinds.as_slice(), + )?; + Ok(EditorPixelArtSourceResolution { + object_key, + asset_kind, + }) +} + +fn resolve_editor_pixel_art_asset_folder_id(asset_folder_id: Option) -> Option { + normalize_optional_string(asset_folder_id) + .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())) +} + +#[derive(Debug, PartialEq, Eq)] +struct EditorPixelArtPersistenceIdentity { + operation_id: String, + operation_fingerprint: String, + task_id: String, + asset_object_id: String, + resource_id: String, + asset_id: String, +} + +fn canonicalize_editor_json_value(value: Value) -> Value { + match value { + Value::Array(items) => Value::Array( + items + .into_iter() + .map(canonicalize_editor_json_value) + .collect(), + ), + Value::Object(object) => { + let sorted = object.into_iter().collect::>(); + Value::Object( + sorted + .into_iter() + .map(|(key, value)| (key, canonicalize_editor_json_value(value))) + .collect(), + ) + } + other => other, + } +} + +fn editor_pixel_art_sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn editor_pixel_art_stable_record_suffix( + owner_user_id: &str, + project_id: &str, + operation_id: &str, + record_kind: &str, +) -> String { + let input = format!( + "editor-pixel-art-result-v1\0{owner_user_id}\0{project_id}\0{operation_id}\0{record_kind}" + ); + editor_pixel_art_sha256_hex(input.as_bytes())[..32].to_string() +} + +#[allow(clippy::too_many_arguments)] +fn build_editor_pixel_art_persistence_identity( + owner_user_id: &str, + project_id: &str, + dialog_id: &str, + source_object_key: &str, + source_image_sha256: &str, + output_image_sha256: &str, + source_resource_id: Option<&str>, + asset_kind: Option<&str>, + asset_folder_id: &str, + asset_label: &str, + generation_inputs: Option<&Value>, + canvas_completion: &EditorCanvasGenerationCompletionRequest, +) -> Result { + let operation_id = dialog_id.to_string(); + let fingerprint_payload = canonicalize_editor_json_value(json!({ + "algorithmVersion": EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION, + "ownerUserId": owner_user_id, + "projectId": project_id, + "dialogId": dialog_id, + "sourceObjectKey": source_object_key, + "sourceImageSha256": source_image_sha256, + "outputImageSha256": output_image_sha256, + "sourceResourceId": source_resource_id, + "assetKind": asset_kind, + "assetFolderId": asset_folder_id, + "assetLabel": asset_label, + "generationInputs": generation_inputs, + "canvasCompletion": { + "dialogId": dialog_id, + "title": canvas_completion.title.trim(), + "placeholder": { + "x": canvas_completion.placeholder.x, + "y": canvas_completion.placeholder.y, + "width": canvas_completion.placeholder.width, + "height": canvas_completion.placeholder.height, + "originalWidth": canvas_completion.placeholder.original_width, + "originalHeight": canvas_completion.placeholder.original_height, + }, + }, + })); + let fingerprint_bytes = serde_json::to_vec(&fingerprint_payload).map_err(|error| { + editor_pixel_art_snap_failure( + StatusCode::INTERNAL_SERVER_ERROR, + format!("完美像素幂等指纹无法序列化:{error}"), + ) + })?; + let operation_fingerprint = editor_pixel_art_sha256_hex(fingerprint_bytes.as_slice()); + let stable_record_id = |prefix: &str, record_kind: &str| { + format!( + "{prefix}{}", + editor_pixel_art_stable_record_suffix( + owner_user_id, + project_id, + operation_id.as_str(), + record_kind, + ) + ) + }; + let asset_object_id = stable_record_id("assetobj_", "asset-object"); + let resource_id = stable_record_id(EDITOR_RESOURCE_ID_PREFIX, "project-resource"); + let asset_id = stable_record_id(EDITOR_ASSET_ID_PREFIX, "asset"); + + Ok(EditorPixelArtPersistenceIdentity { + operation_id, + operation_fingerprint, + task_id: format!("pixel-art-snap-{dialog_id}"), + asset_object_id, + resource_id, + asset_id, + }) +} + +pub async fn snap_editor_image_to_pixel_art( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, AppError> { + let Json(mut payload) = parse_editor_generation_json_payload(payload)?; + // 中文注释:`screenColorHex / mattingProvider / mattingModel` 是服务端产出的处理事实 + // (背景色决策与 bgfilter 实际执行后写入),不接受客户端声明,否则用户可以给自己的记录 + // 伪造抠图模型等审计字段,污染后台按这些字段做的统计与排障。本端点是纯几何规整、不抠图, + // 任何 matting 元数据出现在这里本身就是伪造。与其余生成入口共用同一个 sanitizer,位置也 + // 保持一致:在任何 IO 之前。 + payload.generation_inputs = + sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + payload.generation_inputs = payload + .generation_inputs + .take() + .map(canonicalize_editor_json_value); + let started_at = Instant::now(); + // 中文注释:处理预算必须从进入 handler 起算,而不是等下载完成后才起算。inline HTTP + // 请求的 RequestContext 默认没有 external_call_deadline(只有队列 worker 会设), + // 所以下载阶段此前完全落在 30s 之外:请求可以先挂在 OSS GET 上,再持着最多 32 MiB + // 排队等 CPU 许可,预算形同虚设。这里一次性派生绝对 deadline,下载与规整共用同一份。 + let processing_deadline = resolve_editor_pixel_art_processing_deadline( + started_at, + request_context.external_call_deadline(), + ); + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "pixel-art-snapper", + "sourceImageSrc", + "待像素规整图片", + )?; + let project_id = + normalize_optional_string(Some(payload.project_id.clone())).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "projectId", + "message": "完美像素必须关联有效画布项目。", + })) + })?; + validate_editor_pixel_art_snap_canvas_completion(&payload.canvas_completion)?; + // 中文注释:在 CPU 处理和 OSS PUT 前完成所有无副作用校验;主动操作的像素规整 + // 失败必须直接返回错误,不能先创建与原图相同的派生资源。 + serialize_editor_asset_metadata(payload.generation_inputs.clone())?; + // 中文注释:端点级并发闸设在第一次 IO 之前——上面几步都是纯内存校验,让畸形请求也去 + // 排队既浪费名额又让 400 拖到 30 秒。闸之后的全部 IO(全账号扫描、下载、规整、持久化) + // 都在许可覆盖范围内,许可随 handler 返回自动释放。 + let _snap_permit = acquire_editor_pixel_art_snap_permit(processing_deadline).await?; + let owner_user_id = current_owner_user_id(&authenticated); + // 中文注释:归属校验阶段必须自己套绝对 deadline。预算只是从 handler 入口起算, + // 起算不等于覆盖——此前这段里的 SpacetimeDB 调用全是裸 await,第一次真正应用预算 + // 是下载。SpacetimeDB 慢时请求会一路走到下载才发现预算早已耗尽,返回的还是下载相关 + // 文案,同时全程占着端点准入名额,把并发闸变成瓶颈。 + // 这里不重复写 `Instant::now() >= deadline` 预检:紧邻上一行的 + // `acquire_editor_pixel_art_snap_permit` 已经做过该预检,成功即意味着尚未超预算, + // 且本块首个 await 是网络 IO 不会立即就绪,不构成 timeout_at 先 poll 再判超时的陷阱。 + let source_resource_id = normalize_optional_string(payload.source_resource_id.clone()); + let source = + tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), async { + let project = state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + }) + .await + .map_err(map_editor_project_error)?; + let project = editor_project_payload_from_record(project); + validate_editor_pixel_art_snap_placeholder_exists( + &project.layers, + project.resources.as_slice(), + owner_user_id.as_str(), + project_id.as_str(), + &payload.canvas_completion, + )?; + let source_resource = if let Some(source_resource_id) = source_resource_id.as_deref() { + Some( + project + .resources + .iter() + .find(|resource| resource.resource_id.trim() == source_resource_id) + .ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::NOT_FOUND, + "来源项目资源不存在或不属于当前画布项目。", + ) + })?, + ) + } else { + None + }; + resolve_editor_pixel_art_source_for_owner( + &state, + owner_user_id.as_str(), + project_id.as_str(), + payload.source_image_src.as_str(), + &project, + source_resource, + payload.asset_kind.as_deref(), + ) + .await + }) + .await + .map_err(|_| { + editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素来源归属校验超出处理预算。", + ) + })??; + let source_object_key = source.object_key; + let asset_kind = source.asset_kind; + let source_image = download_editor_persisted_image_object_within_deadline( + &state, + source_object_key.as_str(), + processing_deadline, + ) + .await?; + validate_editor_pixel_art_static_raster(&source_image)?; + let source_image_sha256 = editor_pixel_art_sha256_hex(source_image.bytes.as_slice()); + // 中文注释:传已派生的绝对 deadline 而不是原始 request_deadline。 + // resolve_editor_pixel_art_processing_deadline 取 min,所以内层拿到的仍是这一份, + // 下载耗掉的时间会如实从规整预算里扣除,而不是让规整重新获得完整 30s。 + let snapped_image = + snap_editor_pixel_art_strict(Arc::new(source_image), Some(processing_deadline)).await?; + // 中文注释:只读 PNG 头取尺寸,不做整图解码。这里的字节是本进程 snapper 刚 encode + // 出来的内存缓冲:没有网络截断风险,编码失败会在上一步直接返回 Err,所以不需要像 + // editor_postprocessed_alpha_matches_delivery_dimensions 那样为识别截断像素数据而 + // 整图解码——那条路径处理的是 provider 经网络送来的图。在这里整图解码是纯浪费: + // 输出上限 8294400 像素,一次解码要多分配约 33 MiB 并全量 inflate,而且发生在 + // CPU 许可之外。 + let mut reader = image::ImageReader::new(Cursor::new(snapped_image.bytes.as_slice())); + reader.set_format(image::ImageFormat::Png); + let (width, height) = reader.into_dimensions().map_err(|error| { + editor_pixel_art_snap_failure( + StatusCode::INTERNAL_SERVER_ERROR, + format!("完美像素输出不是有效 PNG:{error}"), + ) + })?; + + let output_image_sha256 = editor_pixel_art_sha256_hex(snapped_image.bytes.as_slice()); + let dialog_id = + normalized_canvas_completion_dialog_id(&payload.canvas_completion).ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "完美像素必须关联有效的画布生成占位。", + ) + })?; + let asset_folder_id = normalize_generated_asset_folder_id( + resolve_editor_pixel_art_asset_folder_id(payload.asset_folder_id.take()), + owner_user_id.as_str(), + ) + .ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::INTERNAL_SERVER_ERROR, + "完美像素结果缺少账号素材目录。", + ) + })?; + let asset_label = resolve_editor_generated_asset_label(payload.asset_label.take(), "完美像素"); + let persistence_identity = build_editor_pixel_art_persistence_identity( + owner_user_id.as_str(), + project_id.as_str(), + dialog_id.as_str(), + source_object_key.as_str(), + source_image_sha256.as_str(), + output_image_sha256.as_str(), + source_resource_id.as_deref(), + asset_kind.as_deref(), + asset_folder_id.as_str(), + asset_label.as_str(), + payload.generation_inputs.as_ref(), + &payload.canvas_completion, + )?; + let response_task_id = persistence_identity.task_id.clone(); + let generation_inputs_json = serialize_editor_asset_metadata(payload.generation_inputs.take())?; + let canvas_completion = EditorPixelArtCanvasCompletionRecordInput { + dialog_id, + title: payload.canvas_completion.title.trim().to_string(), + placeholder: EditorPixelArtCanvasPlaceholderRecordInput { + x: payload.canvas_completion.placeholder.x, + y: payload.canvas_completion.placeholder.y, + width: payload.canvas_completion.placeholder.width, + height: payload.canvas_completion.placeholder.height, + original_width: payload.canvas_completion.placeholder.original_width, + original_height: payload.canvas_completion.placeholder.original_height, + }, + }; + let fingerprint_file_stem = format!( + "perfect-pixel-{}", + persistence_identity.operation_fingerprint + ); + let prepared_upload = prepare_editor_generated_image_object_data( + owner_user_id.as_str(), + persistence_identity.task_id.as_str(), + GeneratedImageAssetDataUrl { + format: normalize_generated_image_asset_mime(snapped_image.mime_type.as_str()), + bytes: snapped_image.bytes, + }, + EDITOR_PIXEL_ART_SNAP_ASSET_KIND, + "pixel-art-snaps", + fingerprint_file_stem.as_str(), + "result", + "genarrative", + )?; + let prepared_object_key = prepared_upload.storage_paths.object_key.clone(); + let image_src = editor_media_src_from_object_key(prepared_object_key.as_str()); + let mut project_resource = EditorProjectResourceCreateRecordInput { + resource_id: persistence_identity.resource_id.clone(), + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + asset_object_id: Some(persistence_identity.asset_object_id.clone()), + image_src: image_src.clone(), + object_key: Some(prepared_object_key), + width, + height, + source_type: "generated".to_string(), + prompt: Some("完美像素".to_string()), + actual_prompt: None, + model: Some(EDITOR_PIXEL_ART_SNAP_MODEL.to_string()), + provider: Some(EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string()), + task_id: Some(persistence_identity.task_id.clone()), + source_resource_id: source_resource_id.clone(), + asset_kind: asset_kind.clone(), + generation_inputs_json: generation_inputs_json.clone(), + updated_at_micros: current_utc_micros(), + }; + // 中文注释:持久化阶段此前完全无界,只受 OSS 客户端每请求 120 秒约束,而 PUT 与 HEAD + // 各自独立计时,加上多次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上, + // 远超客户端 120 秒——客户端会在服务端仍在合法工作时先放弃,对账因此采样到一个仍在 + // 途的操作:占位还在、素材库还空,用户照提示去查什么也看不到,重试就造出孤儿对象。 + // + // 这里独立起算 60 秒,不与处理预算取 min。OSS PUT/HEAD 后只调用一次原子 procedure; + // procedure future 被本地 timeout 丢弃并不能撤销远端事务,因此同一 dialog 的 operation、 + // record IDs 与 fingerprint 都保持稳定,让响应丢失后的同输入重放只能得到同一份结果。 + let persistence_started_at = Instant::now(); + let persistence_deadline = persistence_started_at + .checked_add(EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION) + .unwrap_or(persistence_started_at); + // 中文注释:preflight 与 PUT/HEAD/原子 persist 共用同一个绝对 deadline。preflight + // 是只读 procedure,失败或超时证明第一次 PUT 尚未发出,因此不得附加 unknown 标记。 + tokio::time::timeout_at( + tokio::time::Instant::from_std(persistence_deadline), + state.spacetime_client().preflight_editor_pixel_art_result( + EditorPixelArtResultPreflightRecordInput { + owner_user_id: owner_user_id.clone(), + project_id: project_id.clone(), + asset_folder_id: asset_folder_id.clone(), + project_resource: project_resource.clone(), + canvas_completion: canvas_completion.clone(), + }, + ), + ) + .await + .map_err(|_| { + editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素结果持久化预检超出处理预算。", + ) + })? + .map_err(map_editor_project_error)?; + // 中文注释:Tokio Timeout 会先 poll 内层 future;preflight 与截止同时 ready 时, + // timeout_at 仍可能返回成功。进入任何上传 future 前必须再检查一次绝对截止,避免 + // 第二个 timeout_at 首次 poll 内层并发出已经超预算的 PUT。 + if Instant::now() >= persistence_deadline { + return Err(editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素结果持久化预检超出处理预算。", + )); + } + + let persistence_task_id = response_task_id.clone(); + let result_persistence_started = Arc::new(AtomicBool::new(false)); + let upload_persistence_started = Arc::clone(&result_persistence_started); + let persisted = tokio::select! { + // 中文注释:截止与上传同时 ready 时先选 timer,确保尚未开始的 PUT 不会被首轮 poll。 + biased; + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(persistence_deadline)) => { + let error = editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素结果持久化超出处理预算。", + ); + Err(if result_persistence_started.load(Ordering::Acquire) { + error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + } else { + error + }) + } + result = async move { + let mut uploaded = upload_editor_generated_image_object_prepared( + &state, + owner_user_id.as_str(), + persistence_identity.task_id.as_str(), + prepared_upload, + "完美像素", + EDITOR_PIXEL_ART_SNAP_ASSET_KIND, + Some(persistence_identity.asset_object_id.clone()), + Some(upload_persistence_started.as_ref()), + ) + .await?; + let completed_at_micros = current_utc_micros(); + uploaded.asset_object.content_type = Some("image/png".to_string()); + uploaded.asset_object.content_hash = Some(output_image_sha256); + uploaded.asset_object.updated_at_micros = completed_at_micros; + project_resource.updated_at_micros = completed_at_micros; + state + .spacetime_client() + .persist_editor_pixel_art_result(EditorPixelArtResultPersistRecordInput { + owner_user_id: owner_user_id.clone(), + project_id: project_id.clone(), + operation_id: persistence_identity.operation_id, + operation_fingerprint: persistence_identity.operation_fingerprint, + asset_object: uploaded.asset_object, + project_resource, + asset: EditorAssetCreateRecordInput { + asset_id: persistence_identity.asset_id, + owner_user_id: owner_user_id.clone(), + folder_id: asset_folder_id, + label: asset_label, + asset_object_id: Some(persistence_identity.asset_object_id), + image_src, + object_key: Some(uploaded.object_key), + width, + height, + source_type: "generated".to_string(), + prompt: Some("完美像素".to_string()), + actual_prompt: None, + model: Some(EDITOR_PIXEL_ART_SNAP_MODEL.to_string()), + provider: Some(EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string()), + task_id: Some(persistence_identity.task_id), + asset_kind, + generation_inputs_json, + source_resource_id: Some(persistence_identity.resource_id), + now_micros: completed_at_micros, + thumbnail_src: None, + generation_cost_mud_points: 0, + group_task_id: None, + group_task_expected_asset_count: None, + }, + canvas_completion, + completed_at_micros, + }) + .await + .map_err(|error| { + tracing::warn!( + provider = EDITOR_PIXEL_ART_SNAP_PROVIDER, + task_id = %persistence_task_id, + error = %error, + "editor_pixel_art_snap_atomic_persistence_failed_after_object_put" + ); + map_editor_project_error(error) + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + }) + } => result, + }?; + + let image_src = editor_media_src_from_object_key(persisted.asset_object.object_key.as_str()); + let resource = editor_project_resource_payload_from_record(persisted.project_resource); + let asset = editor_asset_payload_from_record(persisted.asset); + let completed_project = persisted.project.map(editor_project_payload_from_record); + + Ok(json_success_body( + Some(&request_context), + EditorPixelArtSnapResponse { + image_src, + object_key: persisted.asset_object.object_key, + asset_object_id: persisted.asset_object.asset_object_id, + width, + height, + source_type: "generated", + task_id: response_task_id, + elapsed_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + provider: EDITOR_PIXEL_ART_SNAP_PROVIDER, + resource, + asset, + project: completed_project, + }, + )) +} + async fn validate_editor_background_removal_source( state: &AppState, source_object_key: &str, @@ -5036,7 +6319,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( ) .await?; let screen_color = screen_background_decision.color; - let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions, screen_color); + let prompt = apply_editor_pixel_art_style_prompt( + build_editor_icon_spritesheet_prompt(&icon_descriptions, screen_color), + image_style, + EditorPixelArtPromptScope::IconSpritesheet, + ); let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { create_openai_nanobanana_generate_content( &http_client, @@ -7051,6 +8338,7 @@ struct EditorPayloadMediaReference { object_key: Option, asset_object_id: Option, asset_kind: Option, + source_type: Option, } fn sanitize_editor_payload_media( @@ -7068,6 +8356,7 @@ fn sanitize_editor_payload_media( object_key: resource.object_key.clone(), asset_object_id: resource.asset_object_id.clone(), asset_kind: resource.asset_kind.clone(), + source_type: normalize_optional_string(Some(resource.source_type.clone())), }, ) }) @@ -7119,6 +8408,21 @@ fn sanitize_editor_payload_media_value( { object.remove("model"); } + // 中文注释:结构化保存校验完 sourceType 后会把图层列置空(资源行才是权威), + // 于是布局读回时整个键都不存在,客户端只能猜一个默认值再原样回写,下一次保存 + // 就被判成「sourceType 与项目资源不一致」。这里按图层自己声明的 resourceId + // 回填权威值,口径与上面的 objectKey / assetObjectId 一致;缺资源行的 + // legacy 本地序列取不到映射,保持它自带的值不动。 + let resource_source_type = object + .get("resourceId") + .and_then(Value::as_str) + .and_then(|resource_id| resource_media.get(resource_id)) + .and_then(|media| media.source_type.clone()); + fill_missing_media_identity_field( + object, + "sourceType", + resource_source_type.as_ref(), + ); if let Some(generation_inputs) = object.get_mut("generationInputs") { let sanitized = sanitize_editor_reserved_generation_inputs(generation_inputs.take()); @@ -8450,9 +9754,9 @@ fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool fn editor_image_generation_negative_prompt(is_ui_design_generation: bool) -> &'static str { if is_ui_design_generation { - "水印、无界面的纯场景插画、海报、地图、低清晰度、变形主体、不可读布局" + "水印、无界面的纯场景插画、海报、地图、变形主体、不可读布局" } else { - "文字、水印、边框、按钮、UI 控件、低清晰度、变形主体" + "文字、水印、边框、按钮、UI 控件、变形主体" } } @@ -8467,6 +9771,49 @@ fn build_editor_character_image_prompt( .join("\n") } +/// 中文注释:`style="pixelArt"` 此前只驱动 provider 返回后的确定性像素规整,完全不参与提示词。 +/// snapper 是几何对齐器——检测网格步长后按格重采样;provider 交一张柔和渐变图时两轴都测不到 +/// 步长,生成路径用的 legacy profile 会退到 `min(w,h)/64` 统一网格,产出的是马赛克而不是像素 +/// 画。这里在提示词端补一句,让 provider 本身就输出块状结构,snapper 从「抢救」变成「对齐」。 +/// 实测只提「像素风格」效果已可接受,因此不叠加网格密度、色板和抗锯齿等约束。 +/// +/// 普通图片没有抠像底色,整幅画面都该像素化。 +const EDITOR_PIXEL_ART_IMAGE_PROMPT: &str = "画面为像素风格"; +/// 中文注释:角色形象生成后要用 bgfilter 按纯色抠像,绿幕底必须保持平整;同一段提示词里已经 +/// 写死了「纯色背景必须平整无纹理、无渐变」和「禁止出现建筑、室内布景、风景、地面道具、漂浮 +/// 物」,也就是画面里除角色本身没有别的内容。所以只点名角色,不说「画面」。 +const EDITOR_PIXEL_ART_CHARACTER_PROMPT: &str = "角色主体为像素风格"; +/// 中文注释:图标图集同样要抠绿幕底,但一张图里是多个彼此分离的图标素材,逐个点名避免模型 +/// 只把其中一部分做成像素块。 +const EDITOR_PIXEL_ART_ICON_SPRITESHEET_PROMPT: &str = "每个图标素材均为像素风格"; + +/// 中文注释:按链路命名而不是按「整幅画面 / 只有主体」这类抽象作用域分类——三条链路的差异 +/// 不只是作用域,措辞本身还各自绑定各自提示词里已有的约束。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EditorPixelArtPromptScope { + Image, + Character, + IconSpritesheet, +} + +/// 中文注释:追加在末尾而不是前置——风格约束靠后更容易被遵守,也不会和「生成游戏角色立绘」 +/// 这类任务声明抢第一位。非 PixelArt 必须原样返回:普通图片路径的提示词就是用户原文。 +fn apply_editor_pixel_art_style_prompt( + prompt: String, + style: EditorImageGenerationStyle, + scope: EditorPixelArtPromptScope, +) -> String { + if style != EditorImageGenerationStyle::PixelArt { + return prompt; + } + let clause = match scope { + EditorPixelArtPromptScope::Image => EDITOR_PIXEL_ART_IMAGE_PROMPT, + EditorPixelArtPromptScope::Character => EDITOR_PIXEL_ART_CHARACTER_PROMPT, + EditorPixelArtPromptScope::IconSpritesheet => EDITOR_PIXEL_ART_ICON_SPRITESHEET_PROMPT, + }; + format!("{prompt}\n{clause}") +} + #[derive(Debug, Clone, PartialEq, Eq)] struct EditorGenerationOptions { model: &'static str, @@ -8483,6 +9830,51 @@ pub(crate) struct PersistedEditorGeneratedImage { pub(crate) asset_object_id: String, } +struct UploadedEditorGeneratedImageObject { + object_key: String, + asset_object: AssetObjectUpsertInput, +} + +#[allow(clippy::too_many_arguments)] +fn prepare_editor_generated_image_object_data( + owner_user_id: &str, + task_id: &str, + image: GeneratedImageAssetDataUrl, + asset_kind: &str, + path_kind: &str, + file_stem: &str, + slot: &str, + provider: &str, +) -> Result { + GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { + prefix: LegacyAssetPrefix::CharacterDrafts, + path_segments: vec![ + "editor".to_string(), + sanitize_editor_storage_segment(path_kind, "generated-images"), + sanitize_editor_storage_segment(task_id, "task"), + ], + file_stem: sanitize_editor_storage_segment(file_stem, "image"), + image, + access: OssObjectAccess::Private, + metadata: GeneratedImageAssetAdapterMetadata { + asset_kind: Some(asset_kind.to_string()), + owner_user_id: Some(owner_user_id.to_string()), + entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()), + entity_id: Some(task_id.to_string()), + slot: Some(slot.to_string()), + provider: Some(provider.to_string()), + task_id: Some(task_id.to_string()), + }, + extra_metadata: BTreeMap::from([("source".to_string(), "image-canvas-editor".to_string())]), + }) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "generated-image-assets", + "message": format!("准备画板生成图片 OSS 上传请求失败:{error:?}"), + })) + }) +} + struct PersistEditorProviderSourceResourceInput { project_id: Option, owner_user_id: String, @@ -8578,92 +9970,150 @@ async fn persist_editor_generated_image_data( slot: &str, provider: &str, ) -> Result { + let uploaded = upload_editor_generated_image_object_data( + state, + owner_user_id, + task_id, + image, + prompt, + asset_kind, + path_kind, + file_stem, + slot, + provider, + None, + ) + .await?; + let asset_object = state + .spacetime_client() + .confirm_asset_object(uploaded.asset_object) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + })?; + + Ok(PersistedEditorGeneratedImage { + object_key: uploaded.object_key, + asset_object_id: asset_object.asset_object_id, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn upload_editor_generated_image_object_data( + state: &AppState, + owner_user_id: &str, + task_id: &str, + image: GeneratedImageAssetDataUrl, + prompt: &str, + asset_kind: &str, + path_kind: &str, + file_stem: &str, + slot: &str, + provider: &str, + asset_object_id: Option, +) -> Result { + let prepared = prepare_editor_generated_image_object_data( + owner_user_id, + task_id, + image, + asset_kind, + path_kind, + file_stem, + slot, + provider, + )?; + upload_editor_generated_image_object_prepared( + state, + owner_user_id, + task_id, + prepared, + prompt, + asset_kind, + asset_object_id, + None, + ) + .await +} + +async fn upload_editor_generated_image_object_prepared( + state: &AppState, + owner_user_id: &str, + task_id: &str, + prepared: GeneratedImageAssetPreparedPut, + prompt: &str, + asset_kind: &str, + asset_object_id: Option, + result_persistence_started: Option<&AtomicBool>, +) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; - let prepared = - GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { - prefix: LegacyAssetPrefix::CharacterDrafts, - path_segments: vec![ - "editor".to_string(), - sanitize_editor_storage_segment(path_kind, "generated-images"), - sanitize_editor_storage_segment(task_id, "task"), - ], - file_stem: sanitize_editor_storage_segment(file_stem, "image"), - image, - access: OssObjectAccess::Private, - metadata: GeneratedImageAssetAdapterMetadata { - asset_kind: Some(asset_kind.to_string()), - owner_user_id: Some(owner_user_id.to_string()), - entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()), - entity_id: Some(task_id.to_string()), - slot: Some(slot.to_string()), - provider: Some(provider.to_string()), - task_id: Some(task_id.to_string()), - }, - extra_metadata: BTreeMap::from([( - "source".to_string(), - "image-canvas-editor".to_string(), - )]), - }) - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "generated-image-assets", - "message": format!("准备画板生成图片 OSS 上传请求失败:{error:?}"), - })) - })?; let persisted_mime_type = prepared.format.mime_type.clone(); - let http_client = reqwest::Client::new(); + // 中文注释:写路径此前也是每次新建 client,PUT 与 HEAD 都没有超时——请求可以在 + // 上传阶段无限期挂住,而这一段发生在 CPU 处理之后,任何按处理预算派生的 deadline + // 都已经不适用(余额多半为零,传进来只会把算完的结果丢掉)。这里的界只能来自 + // 客户端级超时,与读路径共用同一个进程级客户端。 + let http_client = state.editor_oss_http_client(); + // 中文注释:标记边界就在这一行。上面两处失败(OSS 未配置、prepare_put_object)都发生在 + // 第一次 PUT 之前,标了会让客户端对着什么都没落库的失败去核对素材库,是反向谎报;从 + // PUT 开始(含 PUT 自身——响应丢失时字节可能已经落盘)到本函数返回,一律标记。 + // 调用方拿到的是同一个 AppError,无法自行区分内部走到了哪一步,所以只能在这里标。 + if let Some(result_persistence_started) = result_persistence_started { + result_persistence_started.store(true, Ordering::Release); + } let put_result = oss_client - .put_object(&http_client, prepared.request) + .put_object(http_client, prepared.request) .await - .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + .map_err(|error| { + map_oss_error(error, "aliyun-oss") + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + })?; let head = oss_client .head_object( - &http_client, + http_client, OssHeadObjectRequest { object_key: put_result.object_key.clone(), }, ) .await - .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - let now_micros = current_utc_micros(); - let asset_object = state - .spacetime_client() - .confirm_asset_object( - build_asset_object_upsert_input( - generate_asset_object_id(now_micros), - head.bucket, - head.object_key.clone(), - AssetObjectAccessPolicy::Private, - head.content_type.or(Some(persisted_mime_type)), - head.content_length, - // asset_object.prompt 是跨资源检索用的用户意图,不承载 provider - // actual/system prompt;后者仍只保存在 resource/asset 审计字段。 - Some(prompt.to_string()), - asset_kind.to_string(), - Some(task_id.to_string()), - Some(owner_user_id.to_string()), - None, - Some(task_id.to_string()), - now_micros, - ) - .map_err(map_editor_asset_field_error)?, - ) - .await .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "spacetimedb", - "message": error.to_string(), - })) + map_oss_error(error, "aliyun-oss") + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) })?; + let now_micros = current_utc_micros(); + let asset_object = build_asset_object_upsert_input( + asset_object_id.unwrap_or_else(|| generate_asset_object_id(now_micros)), + head.bucket, + head.object_key.clone(), + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(persisted_mime_type)), + head.content_length, + // asset_object.prompt 是跨资源检索用的用户意图,不承载 provider + // actual/system prompt;后者仍只保存在 resource/asset 审计字段。 + Some(prompt.to_string()), + asset_kind.to_string(), + Some(task_id.to_string()), + Some(owner_user_id.to_string()), + None, + Some(task_id.to_string()), + now_micros, + ) + .map_err(|error| { + map_editor_asset_field_error(error) + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + })?; - Ok(PersistedEditorGeneratedImage { + Ok(UploadedEditorGeneratedImageObject { object_key: head.object_key, - asset_object_id: asset_object.asset_object_id, + asset_object, }) } @@ -9278,8 +10728,11 @@ async fn read_editor_reference_image_object( state: &AppState, source: &str, ) -> Result { - let http_client = reqwest::Client::new(); - read_editor_reference_image_object_with_client(state, source, &http_client).await + // 中文注释:默认调用方走进程级共享客户端。它自带 connect / total 超时,GET 不再 + // 可能无界挂起,顺带复用连接池,避免每次读参考图都重新做一次 TLS 握手。需要按链路 + // 单独调超时的调用方(如图集拆分)改走 `_with_client` 自带客户端。 + read_editor_reference_image_object_with_client(state, source, state.editor_oss_http_client()) + .await } async fn read_editor_reference_image_object_with_client( @@ -9392,6 +10845,28 @@ async fn download_editor_persisted_image_object( }) } +// 中文注释:客户端级超时兜住所有调用方,但像素规整还要求下载本身计入 30s 处理预算。 +// 否则预算只从下载完成后起算:请求可以先花掉客户端的 60s 上限,再持着完整图片去排 +// CPU 许可的队,实际占用远超预算。这里用调用方给出的绝对 deadline 把整段 GET 收进 +// 同一个预算,两层保护各司其职——客户端超时是兜底,deadline 是本次请求的真实上界。 +async fn download_editor_persisted_image_object_within_deadline( + state: &AppState, + object_key: &str, + processing_deadline: Instant, +) -> Result { + tokio::time::timeout_at( + tokio::time::Instant::from_std(processing_deadline), + download_editor_persisted_image_object(state, object_key), + ) + .await + .unwrap_or_else(|_| { + Err(editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "读取待像素规整图片超时。", + )) + }) +} + fn normalize_editor_reference_image_mime_type(content_type: &str) -> Option<&str> { let mime_type = content_type.split(';').next()?.trim(); mime_type.starts_with("image/").then_some(mime_type) @@ -9434,14 +10909,16 @@ pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError "message": message, })) } - SpacetimeClientError::Procedure(message) if message.contains("不存在") => { - AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ + SpacetimeClientError::Procedure(message) + if message.contains("版本冲突") || message.contains("幂等") => + { + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "editor-project", "message": message, })) } - SpacetimeClientError::Procedure(message) if message.contains("版本冲突") => { - AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + SpacetimeClientError::Procedure(message) if message.contains("不存在") => { + AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ "provider": "editor-project", "message": message, })) @@ -9640,6 +11117,75 @@ mod tests { } } + #[test] + fn pixel_art_authorized_source_resource_skips_account_wide_lookup() { + let resource = editor_project_resource_for_canvas_test("res-1", "character", 128, 128); + let object_key = "generated-character-drafts/editor/res-1.png"; + + // sourceImageSrc 就是该 resourceId:无需任何 RPC 即可定位。 + assert_eq!( + resolve_editor_pixel_art_source_without_lookup( + "user-1", + "editor-project-1", + "res-1", + &resource, + ) + .expect("authorized resource should resolve"), + Some(object_key.to_string()) + ); + + // sourceImageSrc 已是同一个 objectKey:同样免 RPC。 + assert_eq!( + resolve_editor_pixel_art_source_without_lookup( + "user-1", + "editor-project-1", + object_key, + &resource, + ) + .expect("matching object key should resolve"), + Some(object_key.to_string()) + ); + + // 中文注释:两个字段指向不同图片必须直接报错,不能落回慢路径——慢路径会用 + // sourceImageSrc 解析出的 key 继续,等于让 sourceResourceId 形同虚设。 + let mismatched = resolve_editor_pixel_art_source_without_lookup( + "user-1", + "editor-project-1", + "generated-character-drafts/editor/other.png", + &resource, + ) + .expect_err("mismatched object key must fail"); + assert_eq!(mismatched.status_code(), StatusCode::BAD_REQUEST); + + // sourceImageSrc 是别的注册 ID:无法免 RPC 判定,落回完整解析。 + assert_eq!( + resolve_editor_pixel_art_source_without_lookup( + "user-1", + "editor-project-1", + "editor-asset-42", + &resource, + ) + .expect("unknown registered id should fall back"), + None + ); + } + + #[test] + fn pixel_art_source_resource_ownership_is_asserted_before_skipping_lookup() { + let resource = editor_project_resource_for_canvas_test("res-1", "character", 128, 128); + + // 中文注释:跳过全账号扫描的前提是这条断言,不能省。 + for (owner, project) in [ + ("other-user", "editor-project-1"), + ("user-1", "other-project"), + ] { + let error = + resolve_editor_pixel_art_source_without_lookup(owner, project, "res-1", &resource) + .expect_err("foreign resource must be rejected"); + assert_eq!(error.status_code(), StatusCode::FORBIDDEN); + } + } + #[test] fn editor_project_procedure_errors_preserve_business_reason_as_bad_request() { let error = map_editor_project_error(SpacetimeClientError::Procedure( @@ -9662,6 +11208,28 @@ mod tests { assert_eq!(error.status_code(), StatusCode::CONFLICT); } + #[test] + fn editor_project_idempotency_conflicts_map_to_http_conflict() { + for message in [ + "完美像素 asset_object 幂等键已被其他内容占用", + "完美像素幂等冲突:dialog 指向的结果图层不存在", + ] { + let error = + map_editor_project_error(SpacetimeClientError::Procedure(message.to_string())); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + } + + let missing_source = map_editor_project_error(SpacetimeClientError::Procedure( + "完美像素来源项目资源不存在".to_string(), + )); + assert_eq!(missing_source.status_code(), StatusCode::NOT_FOUND); + + let invalid_fingerprint = map_editor_project_error(SpacetimeClientError::Procedure( + "完美像素 operation_fingerprint 必须是 64 位小写十六进制".to_string(), + )); + assert_eq!(invalid_fingerprint.status_code(), StatusCode::BAD_REQUEST); + } + #[test] fn editor_project_layout_save_request_requires_expected_revision() { let missing_revision = serde_json::from_value::(json!({ @@ -10493,6 +12061,62 @@ mod tests { assert_eq!(sanitized[1]["dialog"]["provider"], json!("dialog-provider")); } + #[test] + fn editor_payload_sanitizer_refills_layer_source_type_from_resource() { + // 中文注释:结构化保存校验完 sourceType 后会把图层列置空,布局读回时整个键都不存在, + // 客户端只能猜一个默认值再原样回写,下一次保存就被判成「sourceType 与项目资源不一致」。 + // 读边界必须按图层自己声明的 resourceId 回填权威值,口径与 objectKey / assetObjectId 一致。 + let resources = vec![EditorProjectResourcePayload { + resource_id: "resource-1".to_string(), + showcase_id: None, + asset_id: None, + label: None, + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + author_display_name: None, + author_public_user_code: None, + image_src: "/generated-character-drafts/editor/spec.png".to_string(), + object_key: Some("generated-character-drafts/editor/spec.png".to_string()), + asset_object_id: Some("asset-object-1".to_string()), + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + source_resource_id: None, + asset_kind: Some("spec".to_string()), + showcase_category: None, + generation_inputs: None, + public_showcase_enabled: true, + review_status: None, + display_enabled: None, + like_count: None, + generation_cost_mud_points: 0, + refund_mud_points: None, + created_at: "2026-06-23T00:00:00.000Z".to_string(), + updated_at: "2026-06-23T00:00:00.000Z".to_string(), + }]; + + let sanitized = sanitize_editor_payload_media( + json!([ + { "layerId": "layer-1", "resourceId": "resource-1" }, + { + "layerId": "layer-local", + "resourceId": "local-resource-sequence", + "sourceType": "generated" + } + ]), + resources.as_slice(), + ); + + assert_eq!(sanitized[0]["sourceType"], json!("generated")); + // 缺资源行的 legacy 本地序列取不到映射,保持它自带的值不动。 + assert_eq!(sanitized[1]["sourceType"], json!("generated")); + } + #[test] fn editor_canvas_generation_completion_saves_after_sanitizing_legacy_inline_layers() { let existing_resources = vec![EditorProjectResourcePayload { @@ -11050,10 +12674,116 @@ mod tests { assert!( error .expect("expired budget should warn") - .contains("预算已耗尽") + .contains("预算已耗尽,已保留原始生成结果") ); } + #[tokio::test] + async fn pixel_art_strict_expired_budget_returns_error_without_fallback_wording() { + let original = Arc::new(DownloadedOpenAiImage { + bytes: vec![1, 2, 3, 4], + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }); + let expired = Instant::now() + .checked_sub(Duration::from_millis(1)) + .expect("expired test deadline should be representable"); + + let error = snap_editor_pixel_art_strict(original, Some(expired)) + .await + .expect_err("strict pixel snap should surface exhausted budgets"); + + assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); + assert!(error.body_text().contains("预算已耗尽")); + assert!(!error.body_text().contains("已保留原始生成结果")); + } + + #[tokio::test] + async fn pixel_art_strict_rejects_only_when_both_legacy_axes_are_undetected() { + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 128, + 128, + image::Rgba([20, 30, 40, 255]), + )); + let mut bytes = Cursor::new(Vec::new()); + image + .write_to(&mut bytes, image::ImageFormat::Png) + .expect("test PNG should encode"); + let source = DownloadedOpenAiImage { + bytes: bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let strict_error = snap_editor_pixel_art_strict(Arc::new(source.clone()), None) + .await + .expect_err("strict action should reject the legacy uniform-grid fallback"); + let (legacy_output, legacy_error) = + snap_editor_pixel_art_or_original(source.clone(), None).await; + + assert_eq!(strict_error.status_code(), StatusCode::UNPROCESSABLE_ENTITY); + assert!(strict_error.body_text().contains("未识别到")); + assert!(legacy_error.is_none()); + let legacy_output = image::load_from_memory(legacy_output.bytes.as_slice()) + .expect("legacy output should remain a valid image"); + assert_eq!((legacy_output.width(), legacy_output.height()), (128, 128)); + } + + #[test] + fn pixel_art_best_effort_warning_contract_stays_stable() { + let warning = editor_pixel_art_fallback_warning(); + + assert_eq!(warning.code, EDITOR_GENERATION_POSTPROCESS_WARNING_CODE); + assert_eq!(warning.reason, "像素规整未完成,已保留原始生成结果。"); + } + + #[test] + fn pixel_art_snap_queue_guard_is_bounded_and_released_on_drop() { + assert_eq!(EDITOR_PIXEL_ART_SNAP_MAX_CONCURRENCY, 4); + assert_eq!(EDITOR_PIXEL_ART_SNAP_MAX_QUEUE_DEPTH, 2048); + + let depth = AtomicUsize::new(0); + assert!(try_enter_bounded_queue(&depth, 2)); + assert!(try_enter_bounded_queue(&depth, 2)); + // 中文注释:满了必须拒绝,且拒绝时不得把计数推过上限。 + assert!(!try_enter_bounded_queue(&depth, 2)); + assert_eq!(depth.load(Ordering::Acquire), 2); + + // 上限为 0 时任何进入都必须失败。 + let closed = AtomicUsize::new(0); + assert!(!try_enter_bounded_queue(&closed, 0)); + assert_eq!(closed.load(Ordering::Acquire), 0); + } + + #[test] + fn pixel_art_snap_queue_depth_returns_to_zero_after_guards_drop() { + // 中文注释:递减写在 Drop 里,被取消的等待者也必须归还名额;否则计数只增不减, + // 队列会永久「满」,接口彻底不可用。 + let before = EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.load(Ordering::Acquire); + { + let _first = EditorPixelArtSnapQueueGuard::try_enter().expect("queue should accept"); + let _second = EditorPixelArtSnapQueueGuard::try_enter().expect("queue should accept"); + assert_eq!( + EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.load(Ordering::Acquire), + before + 2 + ); + } + assert_eq!( + EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.load(Ordering::Acquire), + before + ); + } + + #[tokio::test] + async fn pixel_art_snap_permit_reports_exhausted_budget_without_waiting() { + let expired = Instant::now() - Duration::from_secs(1); + let error = acquire_editor_pixel_art_snap_permit(expired) + .await + .expect_err("expired budget should not acquire a permit"); + + assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); + } + #[test] fn pixel_art_processing_deadline_uses_earlier_local_or_request_budget() { assert_eq!(EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY, 2); @@ -11076,6 +12806,688 @@ mod tests { ); } + #[test] + fn pixel_art_snap_contended_paths_carry_their_documented_responses() { + use axum::response::IntoResponse; + + // 中文注释:队列满必须是 503 且带 retry-after——客户端与网关据此决定退避,删掉这个头 + // 会让重试立刻打回来,把保险丝变成放大器。此前 `retry-after` 在整个 crate 里只出现在 + // 生产代码一处,零断言。 + let queue_full = editor_pixel_art_snap_queue_full_error(); + assert_eq!(queue_full.status_code(), StatusCode::SERVICE_UNAVAILABLE); + assert!(queue_full.body_text().contains("完美像素排队已满")); + let queue_full_response = queue_full.into_response(); + assert_eq!( + queue_full_response.headers().get("retry-after"), + Some(&HeaderValue::from_static("1")), + "queue-full responses must tell the caller when to retry" + ); + + // 中文注释:等待槽位超时是 504 而不是 503——预算耗尽属于超时语义,错成 503 会让客户端 + // 把「这次来不及了」当成「服务暂时不可用」而立刻重试。 + let wait_timeout = editor_pixel_art_snap_wait_timeout_error(); + assert_eq!(wait_timeout.status_code(), StatusCode::GATEWAY_TIMEOUT); + assert!(wait_timeout.body_text().contains("预算已耗尽")); + + // 中文注释:信号量被关闭是服务端自身不可用,503;同时必须把底层错误带进文案,否则 + // 这条实践中极难复现的路径在排障时只剩一句无信息的通用文案。 + let unavailable = editor_pixel_art_snap_limiter_unavailable_error("semaphore closed"); + assert_eq!(unavailable.status_code(), StatusCode::SERVICE_UNAVAILABLE); + assert!(unavailable.body_text().contains("完美像素并发门限不可用")); + assert!(unavailable.body_text().contains("semaphore closed")); + } + + #[test] + fn pixel_art_snapper_errors_map_to_their_documented_status_codes() { + // 中文注释:四个状态码分档此前只有 GridNotDetected 被间接覆盖。分档错位的后果是双向的: + // 把用户上传的坏图(Decode)报成 500 会让客户端当作服务端故障去重试,把服务端自身失败 + // (Encode / Processing)报成 400 又会让用户以为是自己的输入有问题。 + for (error, expected_status) in [ + ( + platform_image::PixelArtSnapError::InvalidInput("空输入".to_string()), + StatusCode::BAD_REQUEST, + ), + ( + platform_image::PixelArtSnapError::Decode { + input: "source", + message: "corrupt png".to_string(), + }, + StatusCode::BAD_REQUEST, + ), + ( + platform_image::PixelArtSnapError::GridNotDetected, + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + platform_image::PixelArtSnapError::DeadlineExceeded { + stage: "输入解码" + }, + StatusCode::GATEWAY_TIMEOUT, + ), + ( + platform_image::PixelArtSnapError::Encode("encode failed".to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + ), + ( + platform_image::PixelArtSnapError::Processing("resize failed".to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + ), + ] { + let rendered = error.to_string(); + let mapped = map_editor_pixel_art_snapper_error(error); + assert_eq!( + mapped.status_code(), + expected_status, + "unexpected status for {rendered}" + ); + // 中文注释:底层文案必须原样带上。丢掉它,客户端只会看到一句通用失败, + // 「识别不到网格」和「解码失败」在用户侧变得无法区分。 + assert!( + mapped.body_text().contains(rendered.as_str()), + "mapped error should carry the snapper message: {rendered}" + ); + } + } + + #[test] + fn pixel_art_server_worst_case_fits_inside_the_client_timeout() { + // 中文注释:这条不变式是跨端的,钉住它才能防止任一侧被单独调大。客户端 + // `snapEditorImageToPixelArt` 配的是 120 秒;服务端最坏合法时长是处理预算加持久化 + // 预算之和——处理段(准入、归属校验、下载、规整)由 processing_deadline 封顶, + // 持久化段独立起算由 persistence_deadline 封顶。两者相加必须真小于客户端超时, + // 否则客户端会在服务端仍在合法工作时先 abort:对账采样到仍在途的操作,占位还在、 + // 素材库还空,用户照提示核对却什么也看不到,重试就造出孤儿 OSS 对象。 + const CLIENT_TIMEOUT: Duration = Duration::from_secs(120); + let server_worst_case = + EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION + EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION; + + assert_eq!(server_worst_case, Duration::from_secs(90)); + assert!( + server_worst_case < CLIENT_TIMEOUT, + "server worst case {server_worst_case:?} must stay under the client timeout {CLIENT_TIMEOUT:?}" + ); + // 中文注释:余量不能只是「小于」。网络往返、代理缓冲和客户端计时精度都要吃掉一部分, + // 贴着上限等于没有余量。 + assert!( + CLIENT_TIMEOUT - server_worst_case >= Duration::from_secs(30), + "client timeout should keep at least 30s of headroom over the server worst case" + ); + } + + #[test] + fn explicit_pixel_art_snap_contract_preserves_source_metadata() { + let request: EditorPixelArtSnapRequest = serde_json::from_value(json!({ + "sourceImageSrc": "editor-resource-source", + "projectId": "proj-source", + "sourceResourceId": "editor-resource-source", + "assetKind": "character", + "generationInputs": { + "fields": [{ "title": "角色", "value": "陶罐精灵" }], + "references": [], + }, + "assetFolderId": "project", + "assetLabel": "像素角色", + "canvasCompletion": { + "dialogId": "dialog-pixel-art", + "title": "完美像素", + "placeholder": { + "x": 10.0, + "y": 20.0, + "width": 128.0, + "height": 128.0, + "originalWidth": 128.0, + "originalHeight": 128.0 + } + } + })) + .expect("pixel-art snap request should deserialize"); + + assert_eq!(request.project_id, "proj-source"); + assert_eq!( + request.source_resource_id.as_deref(), + Some("editor-resource-source") + ); + assert_eq!(request.asset_kind.as_deref(), Some("character")); + assert_eq!( + request + .generation_inputs + .as_ref() + .and_then(|value| value["fields"][0]["value"].as_str()), + Some("陶罐精灵") + ); + assert_eq!( + request.canvas_completion.dialog_id.as_deref(), + Some("dialog-pixel-art") + ); + } + + #[test] + fn explicit_pixel_art_snap_rejects_non_static_or_forged_asset_kinds() { + assert_eq!( + resolve_editor_pixel_art_snap_asset_kind( + Some("character"), + Some(Some("character")), + &[], + &[], + ) + .expect("matching source kind should be preserved") + .as_deref(), + Some("character") + ); + assert_eq!( + resolve_editor_pixel_art_snap_asset_kind(Some("character"), Some(None), &[], &[]) + .expect("legacy source without a kind may adopt a validated static kind") + .as_deref(), + Some("character") + ); + let mismatch = resolve_editor_pixel_art_snap_asset_kind( + Some("image"), + Some(Some("character")), + &[], + &[], + ) + .expect_err("request kind must not override authoritative source metadata"); + assert_eq!(mismatch.status_code(), StatusCode::BAD_REQUEST); + + for asset_kind in [ + "video", + "editor_uploaded_video", + "audio", + "sound-effect", + "background-music", + "image-sequence", + "character-animation", + ] { + let error = resolve_editor_pixel_art_snap_asset_kind(Some(asset_kind), None, &[], &[]) + .expect_err("non-static source kinds must be rejected"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + } + let discovered_animation = vec!["character-animation".to_string()]; + let omitted_kind_error = + resolve_editor_pixel_art_snap_asset_kind(None, None, &discovered_animation, &[]) + .expect_err("omitting both request metadata fields must not hide source metadata"); + assert_eq!(omitted_kind_error.status_code(), StatusCode::BAD_REQUEST); + + let local_reference_storage_kind = vec!["editor_generation_reference_image".to_string()]; + assert_eq!( + resolve_editor_pixel_art_snap_asset_kind( + Some("character"), + None, + &[], + &local_reference_storage_kind, + ) + .expect("storage taxonomy must not conflict with requested semantic kind") + .as_deref(), + Some("character") + ); + let animated_storage_kind = vec!["character-animation".to_string()]; + let omitted_storage_kind_error = + resolve_editor_pixel_art_snap_asset_kind(None, None, &[], &animated_storage_kind) + .expect_err("omitted request metadata must not bypass asset_object media guard"); + assert_eq!( + omitted_storage_kind_error.status_code(), + StatusCode::BAD_REQUEST + ); + + let image_kind = resolve_editor_pixel_art_snap_asset_kind(Some("image"), None, &[], &[]) + .expect("static image kind should be accepted"); + assert_eq!(generated_canvas_media_type(image_kind.as_deref()), "image"); + } + + #[test] + fn explicit_pixel_art_snap_blank_asset_folder_falls_back_to_project() { + for asset_folder_id in [None, Some(" ".to_string())] { + assert_eq!( + resolve_editor_pixel_art_asset_folder_id(asset_folder_id).as_deref(), + Some(EDITOR_ASSET_DEFAULT_FOLDER_ID) + ); + } + assert_eq!( + resolve_editor_pixel_art_asset_folder_id(Some( + " user-1:asset-folder:custom ".to_string() + )) + .as_deref(), + Some("user-1:asset-folder:custom") + ); + } + + #[test] + fn explicit_pixel_art_snap_identity_is_stable_but_input_drift_changes_fingerprint() { + for (record_kind, expected) in [ + ("asset-object", "8a264e1086ee3d6878d753aec254e0a5"), + ("project-resource", "9eee84b77e8828ca8f5042919198ac1c"), + ("asset", "c7e7e66538f4613226e68001b8408104"), + ] { + assert_eq!( + editor_pixel_art_stable_record_suffix( + "owner-1", + "project-1", + "dialog-1", + record_kind, + ), + expected, + "API 与 SpacetimeDB module 必须共享逐字相同的稳定 ID 算法", + ); + } + let completion: EditorCanvasGenerationCompletionRequest = serde_json::from_value(json!({ + "dialogId": "generation-dialog-7", + "title": "完美像素", + "placeholder": { + "x": 10.0, + "y": 20.0, + "width": 128.0, + "height": 128.0, + "originalWidth": 128.0, + "originalHeight": 128.0 + } + })) + .expect("completion request should deserialize"); + let first_inputs: Value = serde_json::from_str(r#"{"z":1,"nested":{"b":2,"a":1}}"#) + .expect("first generation inputs should parse"); + let reordered_inputs: Value = serde_json::from_str(r#"{"nested":{"a":1,"b":2},"z":1}"#) + .expect("reordered generation inputs should parse"); + let build = |source_hash: &str, + inputs: &Value, + completion: &EditorCanvasGenerationCompletionRequest| { + build_editor_pixel_art_persistence_identity( + "user-1", + "project-1", + "generation-dialog-7", + "generated/editor/source.png", + source_hash, + "output-sha256", + Some("source-resource-1"), + Some("character"), + "user-1:asset-folder:project", + "完美像素", + Some(inputs), + completion, + ) + .expect("pixel-art identity should build") + }; + + let first = build("source-sha256", &first_inputs, &completion); + let reordered = build("source-sha256", &reordered_inputs, &completion); + assert_eq!(first, reordered, "JSON key order must not alter identity"); + assert_eq!(first.operation_id, "generation-dialog-7"); + assert_eq!(first.task_id, "pixel-art-snap-generation-dialog-7"); + assert!(first.asset_object_id.starts_with("assetobj_")); + assert!(first.resource_id.starts_with(EDITOR_RESOURCE_ID_PREFIX)); + assert!(first.asset_id.starts_with(EDITOR_ASSET_ID_PREFIX)); + + let mut padded_completion = completion.clone(); + padded_completion.dialog_id = Some(" generation-dialog-7 ".to_string()); + padded_completion.title = " 完美像素 ".to_string(); + let padded = build("source-sha256", &first_inputs, &padded_completion); + assert_eq!( + first.operation_fingerprint, padded.operation_fingerprint, + "completion fields interpreted with trim semantics must fingerprint identically" + ); + + let drifted = build("different-source-sha256", &first_inputs, &completion); + assert_eq!(first.operation_id, drifted.operation_id); + assert_eq!(first.task_id, drifted.task_id); + assert_eq!(first.asset_object_id, drifted.asset_object_id); + assert_eq!(first.resource_id, drifted.resource_id); + assert_eq!(first.asset_id, drifted.asset_id); + assert_ne!(first.operation_fingerprint, drifted.operation_fingerprint); + } + + #[test] + fn explicit_pixel_art_snap_accepts_only_static_png_jpeg_or_webp_bytes() { + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 4, + 4, + image::Rgba([20, 30, 40, 255]), + )); + let mut png = Cursor::new(Vec::new()); + image + .write_to(&mut png, image::ImageFormat::Png) + .expect("test PNG should encode"); + assert!(validate_editor_pixel_art_static_raster_bytes(png.get_ref()).is_ok()); + assert!(validate_editor_pixel_art_static_raster_bytes(&[0xff, 0xd8, 0xff, 0xd9]).is_ok()); + let mut webp = Cursor::new(Vec::new()); + image + .write_to(&mut webp, image::ImageFormat::WebP) + .expect("test WebP should encode"); + assert!(validate_editor_pixel_art_static_raster_bytes(webp.get_ref()).is_ok()); + + let mut apng = b"\x89PNG\r\n\x1a\n".to_vec(); + apng.extend_from_slice(&8u32.to_be_bytes()); + apng.extend_from_slice(b"acTL"); + apng.extend_from_slice(&[0; 8]); + apng.extend_from_slice(&[0; 4]); + assert!( + validate_editor_pixel_art_static_raster_bytes(apng.as_slice()) + .expect_err("APNG should be rejected") + .contains("APNG") + ); + + let mut animated_webp = b"RIFF".to_vec(); + animated_webp.extend_from_slice(&12u32.to_le_bytes()); + animated_webp.extend_from_slice(b"WEBP"); + animated_webp.extend_from_slice(b"ANIM"); + animated_webp.extend_from_slice(&0u32.to_le_bytes()); + assert!( + validate_editor_pixel_art_static_raster_bytes(animated_webp.as_slice()) + .expect_err("animated WebP should be rejected") + .contains("动画 WebP") + ); + let mut animated_vp8x_webp = b"RIFF".to_vec(); + animated_vp8x_webp.extend_from_slice(&30u32.to_le_bytes()); + animated_vp8x_webp.extend_from_slice(b"WEBP"); + animated_vp8x_webp.extend_from_slice(b"VP8X"); + animated_vp8x_webp.extend_from_slice(&10u32.to_le_bytes()); + animated_vp8x_webp.extend_from_slice(&[0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + animated_vp8x_webp.extend_from_slice(b"VP8L"); + animated_vp8x_webp.extend_from_slice(&0u32.to_le_bytes()); + assert!( + validate_editor_pixel_art_static_raster_bytes(animated_vp8x_webp.as_slice()) + .expect_err("VP8X animation flag should be rejected") + .contains("动画 WebP") + ); + assert!( + validate_editor_pixel_art_static_raster_bytes(b"GIF89a") + .expect_err("GIF should be rejected") + .contains("GIF") + ); + assert!( + validate_editor_pixel_art_static_raster_bytes(b"not-an-image") + .expect_err("unknown image bytes should be rejected") + .contains("PNG、JPEG 或 WebP") + ); + } + + #[test] + fn explicit_pixel_art_snap_requires_non_empty_canvas_dialog_id() { + for dialog_id in [None, Some(" "), Some("dialog\0id")] { + let completion: EditorCanvasGenerationCompletionRequest = + serde_json::from_value(json!({ + "dialogId": dialog_id, + "title": "完美像素", + "placeholder": { + "x": 10.0, + "y": 20.0, + "width": 128.0, + "height": 128.0, + "originalWidth": 128.0, + "originalHeight": 128.0 + } + })) + .expect("completion request should deserialize"); + + let error = validate_editor_pixel_art_snap_canvas_completion(&completion) + .expect_err("pixel-art snap must require a stable dialog id"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + error.details().and_then(|details| details.get("field")), + Some(&json!("canvasCompletion.dialogId")) + ); + } + } + + #[test] + fn explicit_pixel_art_snap_validates_completion_before_persistence() { + let mut completion: EditorCanvasGenerationCompletionRequest = + serde_json::from_value(json!({ + "dialogId": "dialog-pixel-art", + "title": "完美像素", + "placeholder": { + "x": 10.0, + "y": 20.0, + "width": 128.0, + "height": 128.0, + "originalWidth": 128.0, + "originalHeight": 128.0 + } + })) + .expect("completion request should deserialize"); + + completion.title = " ".to_string(); + let blank_title = validate_editor_pixel_art_snap_canvas_completion(&completion) + .expect_err("blank result title must fail before persistence"); + assert_eq!( + blank_title + .details() + .and_then(|details| details.get("field")), + Some(&json!("canvasCompletion.title")) + ); + + completion.title = "完美像素".to_string(); + completion.placeholder.width = 0.0; + let invalid_placeholder = validate_editor_pixel_art_snap_canvas_completion(&completion) + .expect_err("invalid placeholder must fail before persistence"); + assert_eq!( + invalid_placeholder + .details() + .and_then(|details| details.get("field")), + Some(&json!("canvasCompletion.placeholder")) + ); + } + + #[test] + fn explicit_pixel_art_snap_requires_a_persisted_placeholder_before_processing() { + let completion: EditorCanvasGenerationCompletionRequest = serde_json::from_value(json!({ + "dialogId": "dialog-pixel-art", + "title": "完美像素", + "placeholder": { + "x": 10.0, + "y": 20.0, + "width": 128.0, + "height": 128.0, + "originalWidth": 128.0, + "originalHeight": 128.0 + } + })) + .expect("completion request should deserialize"); + let missing = validate_editor_pixel_art_snap_placeholder_exists( + &json!([]), + &[], + "user-1", + "editor-project-1", + &completion, + ) + .expect_err("missing server placeholder should fail before processing"); + assert_eq!(missing.status_code(), StatusCode::CONFLICT); + + let layers = json!([{ + "itemType": "generation-dialog", + "dialog": { + "id": "dialog-pixel-art", + "placeholder": completion.placeholder.clone() + } + }]); + assert!( + validate_editor_pixel_art_snap_placeholder_exists( + &layers, + &[], + "user-1", + "editor-project-1", + &completion, + ) + .is_ok() + ); + + let mut replay_resource = + editor_project_resource_for_canvas_test("placeholder", "image", 128, 128); + replay_resource.resource_id = format!( + "{EDITOR_RESOURCE_ID_PREFIX}{}", + editor_pixel_art_stable_record_suffix( + "user-1", + "editor-project-1", + "dialog-pixel-art", + "project-resource", + ) + ); + replay_resource.task_id = Some("pixel-art-snap-dialog-pixel-art".to_string()); + assert!( + validate_editor_pixel_art_snap_placeholder_exists( + &json!([]), + &[replay_resource], + "user-1", + "editor-project-1", + &completion, + ) + .is_ok(), + "DialogMissing 的同 operation 重放必须进入 procedure 做 exact compare" + ); + } + + #[test] + fn explicit_pixel_art_snap_is_inline_strict_and_persists_only_after_processing() { + let source = include_str!("editor_project.rs"); + assert_function_contains_in_order( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + &[ + // 中文注释:保留审计字段的剥离必须排在最前——它和其余生成入口共用同一个 + // sanitizer,漏掉这一步用户就能给自己的记录伪造 mattingModel 等处理事实。 + // 本端点是纯几何规整、不抠图,任何 matting 元数据出现在这里都是伪造。 + "sanitize_editor_client_generation_inputs", + // 中文注释:处理预算在任何 IO 之前派生,下载与规整共用同一份绝对 deadline; + // 一旦预算改回下载之后起算,下面的顺序断言会先失败。 + "resolve_editor_pixel_art_processing_deadline", + "ensure_editor_reference_image_source_is_stable", + "validate_editor_pixel_art_snap_canvas_completion", + "serialize_editor_asset_metadata", + // 中文注释:并发闸必须排在第一次 IO 之前。挪到 .get_editor_project 之后, + // 全账号扫描与下载就重新回到闸外,等于闸在资源被消耗之后才检查。 + "acquire_editor_pixel_art_snap_permit", + // 中文注释:归属校验阶段必须自己套绝对 deadline。预算只是从 handler 入口 + // 起算,起算不等于覆盖——这段里的 SpacetimeDB 调用一旦退回裸 await,第一次 + // 真正应用预算就又变成下载,请求会一路走到下载才发现预算早已耗尽,而且全程 + // 占着端点准入名额。 + "tokio::time::timeout_at(", + ".get_editor_project", + "validate_editor_pixel_art_snap_placeholder_exists", + "resolve_editor_pixel_art_source_for_owner", + "完美像素来源归属校验超出处理预算。", + "download_editor_persisted_image_object_within_deadline", + "validate_editor_pixel_art_static_raster", + "snap_editor_pixel_art_strict", + "Some(processing_deadline)", + // 中文注释:prepare 只计算精确 object key;只读 preflight 与后续 + // PUT/HEAD/原子 persist 共用第二份 60 秒绝对 deadline。preflight 必须发生 + // 在第一次外部写之前,避免已知的目录/布局拒绝留下 OSS 孤儿对象。 + "prepare_editor_generated_image_object_data(", + "EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION", + "tokio::time::timeout_at(", + ".preflight_editor_pixel_art_result(", + "if Instant::now() >= persistence_deadline", + "tokio::select!", + "biased;", + "upload_editor_generated_image_object_prepared(", + ".persist_editor_pixel_art_result(", + ], + ); + assert_function_contains_in_order( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + &[ + // 中文注释:timer 分支不能因为 preflight 已成功就谎报已 PUT;只有执行 + // helper 在第一次 PUT 前置位后,持久化超时才附加 unknown 标记。 + "AtomicBool::new(false)", + "tokio::select!", + "result_persistence_started.load(Ordering::Acquire)", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + "upload_editor_generated_image_object_prepared(", + ], + ); + assert_function_occurrence_count( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + ".preflight_editor_pixel_art_result(", + 1, + ); + assert_function_occurrence_count( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + ".persist_editor_pixel_art_result(", + 1, + ); + // 中文注释:全账号扫描各只能出现一次。此前解析链路是三个各自取数的函数串联 + // (resolve_editor_reference_object_key + ensure_editor_reference_object_key_owned + // 各扫一轮,本解析器再扫第三轮),同一份数据被拉了 6 次全账号 RPC,全部顺序执行 + // 在预算和准入名额之内。改为扫一次后交给 `_from_records` 纯函数在内存里解析; + // 一旦有人重新引入取数包装,这两条计数会立刻失败。 + for (scan, expected_occurrences) in [ + (".list_editor_projects(", 1), + (".get_editor_asset_library(", 1), + ] { + assert_function_occurrence_count( + source, + "async fn resolve_editor_pixel_art_source_for_owner", + "fn resolve_editor_pixel_art_asset_folder_id", + scan, + expected_occurrences, + ); + } + assert_function_not_contains( + source, + "async fn resolve_editor_pixel_art_source_for_owner", + "fn resolve_editor_pixel_art_asset_folder_id", + &[ + // 中文注释:这个包装内部自带两轮全账号扫描,已经持有 owner 快照的调用方 + // 不能再走它,否则同一份数据会被重复拉取三倍。断言调用形式而不是裸函数名, + // 否则会命中生产代码里说明「老包装保持不动」的那句注释。 + "resolve_editor_reference_object_key_for_owner(state", + ], + ); + // 中文注释:第一次 OSS PUT 之后的 procedure 错误与整段 timeout 都必须带 + // resultPersistenceStarted。远端 procedure 是单事务,但本地 future 被 timeout/drop + // 不能撤销远端调用,因此这两条仍是未知结果;PUT/HEAD/helper 构造错误由 helper 测试 + // 单独钉住。 + assert_function_occurrence_count( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + 2, + ); + assert_function_contains_in_order( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + &[ + // 中文注释:标记只能出现在第一次 PUT 之后。出现在 persist 之前说明有纯 + // 校验失败被误标成「可能已落库」,会让常见的 400 也触发多余的对账读取。 + ".preflight_editor_pixel_art_result(", + "upload_editor_generated_image_object_prepared(", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + ], + ); + assert_function_not_contains( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + &[ + "enqueue_editor_generation_job", + "external_generation_job", + "queue_state", + "snap_editor_pixel_art_or_original", + "confirm_asset_object(", + "persist_editor_generated_asset(", + "complete_editor_canvas_generation(", + "create_editor_project_resource(", + "create_editor_asset(", + // 中文注释:下载必须走带 deadline 的包装。退回裸调用会让 OSS GET 重新 + // 落在 30s 预算之外,请求可以持着最多 32 MiB 无限期排队等 CPU 许可。 + "download_editor_persisted_image_object(&state", + // 中文注释:完美像素必须走 upload-only helper;这些旧包装会在新原子 + // procedure 之前单独 confirm asset object,重新制造 object-only 部分状态。 + "persist_editor_generated_image(", + "persist_editor_generated_image_owned(", + // 中文注释:取输出尺寸只读 PNG 头。整图解码发生在 CPU 许可之外, + // 对 8294400 像素上限的输出要多分配约 33 MiB,且不换来任何保证。 + "image::load_from_memory", + ], + ); + } + #[test] fn editor_image_generation_returns_never_drop_accumulated_warnings() { let source = include_str!("editor_project.rs"); @@ -11652,7 +14064,85 @@ mod tests { assert_eq!( editor_image_generation_negative_prompt(false), - "文字、水印、边框、按钮、UI 控件、低清晰度、变形主体" + "文字、水印、边框、按钮、UI 控件、变形主体" + ); + } + + #[test] + fn pixel_art_style_prompt_is_scoped_per_generation_path() { + // 中文注释:非 PixelArt 必须原样返回——普通图片路径的提示词就是用户原文, + // 任何多余追加都会直接落进 editor_project_resource 的 prompt 列。 + for scope in [ + EditorPixelArtPromptScope::Image, + EditorPixelArtPromptScope::Character, + EditorPixelArtPromptScope::IconSpritesheet, + ] { + assert_eq!( + apply_editor_pixel_art_style_prompt( + "一只猫".to_string(), + EditorImageGenerationStyle::None, + scope, + ), + "一只猫" + ); + } + + let image_prompt = apply_editor_pixel_art_style_prompt( + "一只猫".to_string(), + EditorImageGenerationStyle::PixelArt, + EditorPixelArtPromptScope::Image, + ); + assert_eq!(image_prompt, "一只猫\n画面为像素风格"); + + // 中文注释:角色形象与图标图集生成后都要按纯色抠像,绿幕底必须保持平整, + // 因此这两条都不能出现「画面」级别的像素化要求,否则和同一段提示词里的 + // 「纯色背景必须平整无纹理、无渐变」互相拆台。 + let character_prompt = apply_editor_pixel_art_style_prompt( + "一只猫".to_string(), + EditorImageGenerationStyle::PixelArt, + EditorPixelArtPromptScope::Character, + ); + assert_eq!(character_prompt, "一只猫\n角色主体为像素风格"); + + let icon_prompt = apply_editor_pixel_art_style_prompt( + "一只猫".to_string(), + EditorImageGenerationStyle::PixelArt, + EditorPixelArtPromptScope::IconSpritesheet, + ); + assert_eq!(icon_prompt, "一只猫\n每个图标素材均为像素风格"); + + for scoped_prompt in [character_prompt.as_str(), icon_prompt.as_str()] { + assert!(!scoped_prompt.contains(EDITOR_PIXEL_ART_IMAGE_PROMPT)); + } + } + + #[test] + fn pixel_art_style_prompt_is_applied_before_provider_call() { + let source = include_str!("editor_project.rs"); + + // 中文注释:注入必须发生在 provider 调用之前——一旦被挪到之后,提示词约束就完全 + // 不起作用,而产物看起来仍然「成功」,只是退化成 snapper 的统一网格马赛克。 + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_image_for_owner", + "fn normalize_editor_image_generation_size", + &[ + "let pixel_art_prompt_scope = if is_character_generation {", + "EditorPixelArtPromptScope::Character", + "EditorPixelArtPromptScope::Image", + "apply_editor_pixel_art_style_prompt(", + "create_openai_", + ], + ); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn extract_editor_ui_design_assets", + &[ + "apply_editor_pixel_art_style_prompt(", + "EditorPixelArtPromptScope::IconSpritesheet", + "create_openai_", + ], ); } @@ -14791,13 +17281,103 @@ mod tests { source, "async fn read_editor_reference_image_object", "async fn download_editor_persisted_image_object", - &["response.chunk().await", "bytes.extend_from_slice"], + &[ + "response.chunk().await", + "bytes.extend_from_slice", + // 中文注释:必须走进程级共享客户端。它自带 connect / total 超时, + // 是所有 OSS 读取调用方(含没有 deadline 可传的降级路径)的兜底上界。 + ".editor_oss_http_client()", + ], ); assert_function_not_contains( source, "async fn read_editor_reference_image_object", "async fn download_editor_persisted_image_object", - &["response.bytes().await", "bytes.to_vec()"], + &[ + "response.bytes().await", + "bytes.to_vec()", + // 中文注释:每次新建客户端会同时丢掉超时与连接复用,等于把无界 GET 放回来。 + "reqwest::Client::new()", + ], + ); + // 中文注释:写路径与读路径同一条不变式。persist 发生在 CPU 处理之后,处理预算 + // 已经不适用,客户端级超时是它唯一的界;退回裸客户端会让 PUT / HEAD 重新无界, + // 而这一段是全部 9 条编辑器图片持久化流程共用的。 + assert_function_contains( + source, + "async fn persist_editor_generated_image_data", + "async fn persist_editor_provider_source_image", + &["state.editor_oss_http_client()"], + ); + assert_function_not_contains( + source, + "async fn persist_editor_generated_image_data", + "async fn persist_editor_provider_source_image", + &["reqwest::Client::new()"], + ); + // 中文注释:旧包装仍在 upload-only helper 之后 confirm asset object,供兄弟链路 + // 保持原行为;完美像素只走 upload-only helper,再把 object/resource/asset/canvas + // 交给单个原子 procedure。两层边界必须分别钉住,防止 confirm 再次溜回新链路。 + assert_function_contains_in_order( + source, + "async fn persist_editor_generated_image_data", + "async fn upload_editor_generated_image_object_data", + &[ + "upload_editor_generated_image_object_data", + ".confirm_asset_object(uploaded.asset_object)", + ], + ); + assert_function_not_contains( + source, + "async fn upload_editor_generated_image_object_data", + "async fn persist_editor_provider_source_image", + &["confirm_asset_object("], + ); + // 中文注释:调用方拿到的是同一个 AppError,无法自行判断本函数内部走到了哪一步, + // 所以「失败发生在持久化开始之后」只能在这里标。整个旧包装的四处分别是 PUT + // (响应丢失时字节可能已落盘)、HEAD、asset object 入参构造与旧 confirm。 + assert_function_occurrence_count( + source, + "async fn persist_editor_generated_image_data", + "async fn persist_editor_provider_source_image", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + 4, + ); + assert_function_occurrence_count( + source, + "async fn upload_editor_generated_image_object_data", + "async fn persist_editor_provider_source_image", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + 3, + ); + // 中文注释:prepare 只能计算 request/object key,不得访问 OSS 或谎报已经开始持久化。 + assert_function_contains( + source, + "fn prepare_editor_generated_image_object_data", + "struct PersistEditorProviderSourceResourceInput", + &["prepare_put_object"], + ); + assert_function_not_contains( + source, + "fn prepare_editor_generated_image_object_data", + "struct PersistEditorProviderSourceResourceInput", + &[ + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + ".put_object(", + ".head_object(", + ], + ); + // 中文注释:执行 helper 的标记必须在第一次 PUT 之后才出现。OSS 未配置仍发生在 PUT + // 之前,标了是反向谎报——会让客户端对着什么都没落库的失败去核对素材库。 + assert_function_contains_in_order( + source, + "async fn upload_editor_generated_image_object_prepared", + "async fn persist_editor_provider_source_image", + &[ + "result_persistence_started.store(true, Ordering::Release)", + ".put_object(http_client, prepared.request)", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + ], ); } diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index ac061d6d9..9ce320983 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -68,6 +68,24 @@ impl AppError { self } + /// 中文注释:在已有 details 上补一个字段,而不是像 `with_details` 那样整体替换。 + /// 用于给沿途传上来的错误追加旁路信息(例如「失败发生在持久化开始之后」),同时保留 + /// 下游原本写入的 provider / message 等字段——客户端要靠 message 定位,靠新字段决策。 + pub fn with_detail_field(mut self, key: &'static str, value: Value) -> Self { + match self.details.take() { + Some(Value::Object(mut object)) => { + object.insert(key.to_string(), value); + self.details = Some(Value::Object(object)); + } + // 中文注释:details 为空或不是对象时按对象重建。本仓库的 details 一律是对象, + // 走到后一个分支说明调用方写法有变,宁可保留标记也不静默丢弃。 + _ => { + self.details = Some(serde_json::json!({ key: value })); + } + } + self + } + pub fn with_header(mut self, name: &'static str, value: HeaderValue) -> Self { self.headers.insert(name, value); self diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 90747e184..43bb64cd0 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -22,9 +22,9 @@ use crate::{ get_editor_asset_library, get_editor_generation_pricing, get_editor_project, list_editor_projects, list_public_editor_project_resources, load_recent_editor_project, remove_editor_image_background, rename_editor_project, save_editor_project_layout, - split_editor_icon_spritesheet, submit_editor_asset_showcase, - toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder, - update_editor_project_resource_showcase, + snap_editor_image_to_pixel_art, split_editor_icon_spritesheet, + submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset, + update_editor_asset_folder, update_editor_project_resource_showcase, }, state::AppState, }; @@ -205,6 +205,13 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/editor/images/pixel-art-snaps", + post(snap_editor_image_to_pixel_art).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/editor/icon-spritesheets/generations", post(generate_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 745819c6e..af376db99 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -272,6 +272,7 @@ pub struct AppStateInner { bgfilter_image_validation_limiter: Arc, character_animation_oss_http_client: reqwest::Client, character_animation_oss_io_limiter: Arc, + editor_oss_http_client: reqwest::Client, #[cfg(any())] creative_agent_executor: Arc, // Phase 1 任务 E 的 creative session facade 暂存在 api-server。 @@ -530,6 +531,7 @@ impl AppState { let character_animation_oss_http_client = build_character_animation_oss_http_client()?; let character_animation_oss_io_limiter = Arc::new(Semaphore::new(CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY)); + let editor_oss_http_client = build_editor_oss_http_client()?; let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); let (profile_recharge_order_updates, _) = broadcast::channel(128); @@ -577,6 +579,7 @@ impl AppState { bgfilter_image_validation_limiter, character_animation_oss_http_client, character_animation_oss_io_limiter, + editor_oss_http_client, #[cfg(any())] creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), #[cfg(any())] @@ -1317,6 +1320,10 @@ impl AppState { self.character_animation_oss_io_limiter.clone() } + pub fn editor_oss_http_client(&self) -> &reqwest::Client { + &self.editor_oss_http_client + } + #[cfg(any())] pub fn creative_agent_executor(&self) -> Arc { self.creative_agent_executor.clone() @@ -2089,6 +2096,31 @@ fn build_character_animation_oss_http_client() -> Result Result { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(120)) + .pool_idle_timeout(std::time::Duration::from_secs(300)) + .pool_max_idle_per_host(8) + .tcp_keepalive(std::time::Duration::from_secs(60)) + .build() + .map_err(|error| { + AppStateInitError::DependencyUnavailable(format!( + "初始化编辑器 OSS HTTP 客户端失败:{error}" + )) + }) +} + fn build_wechat_client(config: &AppConfig) -> WechatClient { WechatClient::new(WechatConfig { app_id: config.wechat_mini_program_app_id.clone(), @@ -2236,6 +2268,16 @@ mod tests { ); } + #[test] + fn app_state_reuses_editor_oss_client() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + + assert!(std::ptr::eq( + state.editor_oss_http_client(), + state.editor_oss_http_client(), + )); + } + #[test] fn bgfilter_image_validation_limiter_is_bounded_per_process_role() { let parent = AppState::new(AppConfig::default()).expect("parent state should build"); diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index 41289d985..8db6a9c97 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -5,7 +5,8 @@ pub mod vector_engine; pub use pixel_art_snapper::{ PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, PIXEL_ART_ANALYSIS_COLORS, PIXEL_ART_KMEANS_SAMPLE_LIMIT, - PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art, snap_pixel_art_with_deadline, + PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art_strict_with_deadline, + snap_pixel_art_with_deadline, }; pub use vector_engine::{ DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL, diff --git a/server-rs/crates/platform-image/src/pixel_art_snapper.rs b/server-rs/crates/platform-image/src/pixel_art_snapper.rs index 8ca7635b8..8e46a169d 100644 --- a/server-rs/crates/platform-image/src/pixel_art_snapper.rs +++ b/server-rs/crates/platform-image/src/pixel_art_snapper.rs @@ -48,6 +48,7 @@ const DEADLINE_CHECK_INTERVAL: usize = 4_096; #[derive(Debug)] pub enum PixelArtSnapError { InvalidInput(String), + GridNotDetected, Decode { input: &'static str, message: String, @@ -63,6 +64,7 @@ impl fmt::Display for PixelArtSnapError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidInput(message) => write!(formatter, "像素规整输入无效:{message}"), + Self::GridNotDetected => write!(formatter, "像素规整未识别到可规整的像素网格"), Self::Decode { input, message } => { write!(formatter, "像素规整无法解码 {input}:{message}") } @@ -154,23 +156,38 @@ impl SnapConfig { /// image is always a PNG at the original `rgba_source` dimensions. Its alpha /// channel contains only `0` or `255`, and fully transparent pixels are /// canonical `[0, 0, 0, 0]`. -pub fn snap_pixel_art( - grid_source: &DownloadedImage, - rgba_source: &DownloadedImage, -) -> Result { - snap_pixel_art_with_deadline(grid_source, rgba_source, None) -} - -/// Deadline-aware variant of [`snap_pixel_art`]. /// /// The deadline is checked before and after non-cooperative codec/resize /// operations, and periodically inside the K-means, profile, and cell-sampling /// loops. Exceeding it returns [`PixelArtSnapError::DeadlineExceeded`] without -/// producing a partial image. +/// producing a partial image. Pass `None` to opt out of deadline checks. pub fn snap_pixel_art_with_deadline( grid_source: &DownloadedImage, rgba_source: &DownloadedImage, deadline: Option, +) -> Result { + snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, false) +} + +/// Snap an image unless legacy analysis detects no grid step on either axis. +/// +/// Unlike [`snap_pixel_art_with_deadline`], this entry does not synthesize a +/// uniform min-dimension/64 grid when neither axis contains a detectable step. +/// Every other detection, walking, sampling, and encoding behavior — including +/// the deadline semantics — is identical. +pub fn snap_pixel_art_strict_with_deadline( + grid_source: &DownloadedImage, + rgba_source: &DownloadedImage, + deadline: Option, +) -> Result { + snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, true) +} + +fn snap_pixel_art_with_grid_policy( + grid_source: &DownloadedImage, + rgba_source: &DownloadedImage, + deadline: Option, + reject_uniform_grid_fallback: bool, ) -> Result { let deadline = DeadlineGuard::new(deadline); deadline.check("输入解码")?; @@ -197,6 +214,9 @@ pub fn snap_pixel_art_with_deadline( let (profile_x, profile_y) = compute_profiles(&quantized_grid, deadline)?; let estimated_x = estimate_step_size(&profile_x, config); let estimated_y = estimate_step_size(&profile_y, config); + if reject_uniform_grid_fallback && estimated_x.is_none() && estimated_y.is_none() { + return Err(PixelArtSnapError::GridNotDetected); + } let (step_x, step_y) = resolve_step_sizes( estimated_x, estimated_y, @@ -1076,7 +1096,8 @@ mod tests { let grid = downloaded_png(RgbaImage::from_pixel(8, 8, Rgba([10, 20, 30, 255]))); let rgba = downloaded_png(RgbaImage::from_pixel(8, 9, Rgba([10, 20, 30, 255]))); - let error = snap_pixel_art(&grid, &rgba).expect_err("dimensions should mismatch"); + let error = snap_pixel_art_with_deadline(&grid, &rgba, None) + .expect_err("dimensions should mismatch"); assert!( error .to_string() @@ -1102,6 +1123,58 @@ mod tests { )); } + #[test] + fn strict_mode_rejects_only_when_legacy_would_use_uniform_fallback() { + let source = downloaded_png(RgbaImage::from_pixel(128, 128, Rgba([10, 20, 30, 255]))); + + let legacy = snap_pixel_art_with_deadline(&source, &source, None) + .expect("legacy generation style should retain its uniform fallback"); + let strict = snap_pixel_art_strict_with_deadline(&source, &source, None) + .expect_err("explicit strict action should reject an undetected grid"); + + assert_eq!(decode_output(&legacy).dimensions(), (128, 128)); + assert!(matches!(strict, PixelArtSnapError::GridNotDetected)); + } + + #[test] + fn strict_mode_matches_legacy_when_either_axis_has_a_detected_step() { + for (vertical_lines, horizontal_lines, expected_axes) in [ + (true, false, (true, false)), + (false, true, (false, true)), + (true, true, (true, true)), + ] { + let mut image = RgbaImage::from_pixel(128, 128, Rgba([10, 20, 30, 255])); + for y in 0..128 { + for x in 0..128 { + if (vertical_lines && x % 8 == 0) || (horizontal_lines && y % 8 == 0) { + image.put_pixel(x, y, Rgba([240, 220, 80, 255])); + } + } + } + let config = SnapConfig::PRODUCTION; + let quantized = quantize_for_analysis(&image, config, DeadlineGuard::new(None)) + .expect("test image should quantize"); + let (profile_x, profile_y) = compute_profiles(&quantized, DeadlineGuard::new(None)) + .expect("test profiles should compute"); + assert_eq!( + ( + estimate_step_size(&profile_x, config).is_some(), + estimate_step_size(&profile_y, config).is_some(), + ), + expected_axes, + ); + + let source = downloaded_png(image); + let legacy = snap_pixel_art_with_deadline(&source, &source, None) + .expect("legacy processing should succeed with a detected step"); + let strict = snap_pixel_art_strict_with_deadline(&source, &source, None) + .expect("strict processing should reuse the detected legacy step"); + assert_eq!(strict.bytes, legacy.bytes); + assert_eq!(strict.mime_type, legacy.mime_type); + assert_eq!(strict.extension, legacy.extension); + } + } + #[test] fn output_keeps_physical_size_and_uses_nearest_blocks() { let grid_image = RgbaImage::from_pixel(128, 128, Rgba([0, 0, 0, 255])); @@ -1121,9 +1194,10 @@ mod tests { } } - let output = snap_pixel_art( + let output = snap_pixel_art_with_deadline( &downloaded_png(grid_image), &downloaded_png(rgba_image.clone()), + None, ) .expect("pixel snapping should succeed"); let decoded = decode_output(&output); @@ -1150,8 +1224,10 @@ mod tests { } let rgba = downloaded_png(rgba); - let first = snap_pixel_art(&grid, &rgba).expect("first snap should succeed"); - let second = snap_pixel_art(&grid, &rgba).expect("second snap should succeed"); + let first = + snap_pixel_art_with_deadline(&grid, &rgba, None).expect("first snap should succeed"); + let second = + snap_pixel_art_with_deadline(&grid, &rgba, None).expect("second snap should succeed"); assert_eq!(first.bytes, second.bytes); for pixel in decode_output(&first).pixels() { diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index 2e991dc7c..bcb1c3237 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -1,5 +1,111 @@ use super::*; +#[derive(Clone, Debug, PartialEq)] +pub struct EditorPixelArtCanvasPlaceholderRecordInput { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub original_width: f64, + pub original_height: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EditorPixelArtCanvasCompletionRecordInput { + pub dialog_id: String, + pub title: String, + pub placeholder: EditorPixelArtCanvasPlaceholderRecordInput, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EditorPixelArtResultPreflightRecordInput { + pub owner_user_id: String, + pub project_id: String, + pub asset_folder_id: String, + pub project_resource: EditorProjectResourceCreateRecordInput, + pub canvas_completion: EditorPixelArtCanvasCompletionRecordInput, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EditorPixelArtResultPersistRecordInput { + pub owner_user_id: String, + pub project_id: String, + pub operation_id: String, + pub operation_fingerprint: String, + pub asset_object: module_assets::AssetObjectUpsertInput, + pub project_resource: EditorProjectResourceCreateRecordInput, + pub asset: EditorAssetCreateRecordInput, + pub canvas_completion: EditorPixelArtCanvasCompletionRecordInput, + pub completed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EditorPixelArtResultPersistRecord { + pub asset_object: module_assets::AssetObjectUpsertSnapshot, + pub project_resource: EditorProjectResourceRecord, + pub asset: EditorAssetRecord, + pub project: Option, +} + +impl From + for crate::module_bindings::EditorPixelArtCanvasPlaceholderInput +{ + fn from(input: EditorPixelArtCanvasPlaceholderRecordInput) -> Self { + Self { + x: input.x, + y: input.y, + width: input.width, + height: input.height, + original_width: input.original_width, + original_height: input.original_height, + } + } +} + +impl From + for crate::module_bindings::EditorPixelArtCanvasCompletionInput +{ + fn from(input: EditorPixelArtCanvasCompletionRecordInput) -> Self { + Self { + dialog_id: input.dialog_id, + title: input.title, + placeholder: input.placeholder.into(), + } + } +} + +impl From + for crate::module_bindings::EditorPixelArtResultPreflightInput +{ + fn from(input: EditorPixelArtResultPreflightRecordInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + project_id: input.project_id, + asset_folder_id: input.asset_folder_id, + project_resource: input.project_resource.into(), + canvas_completion: input.canvas_completion.into(), + } + } +} + +impl From + for crate::module_bindings::EditorPixelArtResultPersistInput +{ + fn from(input: EditorPixelArtResultPersistRecordInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + project_id: input.project_id, + operation_id: input.operation_id, + operation_fingerprint: input.operation_fingerprint, + asset_object: input.asset_object.into(), + project_resource: input.project_resource.into(), + asset: input.asset.into(), + canvas_completion: input.canvas_completion.into(), + completed_at_micros: input.completed_at_micros, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct EditorSpritesheetSlicePersistItemRecordInput { pub asset_object: module_assets::AssetObjectUpsertInput, @@ -57,6 +163,56 @@ impl From } impl SpacetimeClient { + pub async fn preflight_editor_pixel_art_result( + &self, + input: EditorPixelArtResultPreflightRecordInput, + ) -> Result<(), SpacetimeClientError> { + let procedure_input = input.into(); + + self.call_after_connect( + "preflight_editor_pixel_art_result_and_return", + move |connection, sender| { + connection + .procedures() + .preflight_editor_pixel_art_result_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_pixel_art_result_preflight_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn persist_editor_pixel_art_result( + &self, + input: EditorPixelArtResultPersistRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "persist_editor_pixel_art_result_and_return", + move |connection, sender| { + connection + .procedures() + .persist_editor_pixel_art_result_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_pixel_art_result_persist_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn persist_editor_spritesheet_slice_batch( &self, input: EditorSpritesheetSliceBatchPersistRecordInput, @@ -981,6 +1137,59 @@ impl SpacetimeClient { } } +fn map_editor_pixel_art_result_preflight_result( + result: crate::module_bindings::EditorPixelArtResultPreflightResult, +) -> Result<(), SpacetimeClientError> { + if result.ok { + return Ok(()); + } + Err(SpacetimeClientError::procedure_failed(result.error_message)) +} + +fn map_editor_pixel_art_result_persist_result( + result: crate::module_bindings::EditorPixelArtResultPersistResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + // 中文注释:三种落库状态(Applied / DialogMissing / AlreadyApplied)目前没有任何 + // 上层消费方,不再向上透传;但状态缺失说明 procedure 返回体不完整,仍要拦在这里。 + if result.status.is_none() { + return Err(SpacetimeClientError::validation_failed( + "完美像素持久化结果缺少状态", + )); + } + let asset_object = result.asset_object.ok_or_else(|| { + SpacetimeClientError::validation_failed("完美像素持久化结果缺少 asset object") + })?; + let project_resource = result + .project_resource + .ok_or_else(|| SpacetimeClientError::validation_failed("完美像素持久化结果缺少项目资源"))?; + let asset = result + .asset + .ok_or_else(|| SpacetimeClientError::validation_failed("完美像素持久化结果缺少账号素材"))?; + let project = result + .project + .map(|project| { + map_editor_project_optional_procedure_result( + crate::module_bindings::EditorProjectProcedureResult { + ok: true, + project: Some(project), + error_message: None, + }, + ) + }) + .transpose()? + .flatten(); + + Ok(EditorPixelArtResultPersistRecord { + asset_object: map_editor_spritesheet_asset_object_snapshot(asset_object), + project_resource: map_editor_spritesheet_project_resource_snapshot(project_resource)?, + asset: map_editor_spritesheet_asset_snapshot(asset)?, + project, + }) +} + fn map_editor_spritesheet_slice_batch_persist_result( result: crate::module_bindings::EditorSpritesheetSliceBatchPersistResult, ) -> Result { @@ -1113,3 +1322,181 @@ fn parse_editor_spritesheet_generation_inputs( }) .transpose() } + +#[cfg(test)] +mod pixel_art_persist_mapper_tests { + use super::*; + + fn empty_pixel_art_persist_result( + ok: bool, + ) -> crate::module_bindings::EditorPixelArtResultPersistResult { + crate::module_bindings::EditorPixelArtResultPersistResult { + ok, + status: None, + asset_object: None, + project_resource: None, + asset: None, + project: None, + error_message: None, + } + } + + fn pixel_art_asset_object_snapshot() -> crate::module_bindings::AssetObjectUpsertSnapshot { + crate::module_bindings::AssetObjectUpsertSnapshot { + asset_object_id: "assetobj_pixel".to_string(), + bucket: "private".to_string(), + object_key: "editor/pixel.png".to_string(), + access_policy: crate::module_bindings::AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 16, + content_hash: Some("output-sha256".to_string()), + version: 1, + source_job_id: Some("pixel-art-snap-dialog-1".to_string()), + owner_user_id: Some("user-1".to_string()), + profile_id: None, + entity_id: Some("project-1".to_string()), + asset_kind: "image".to_string(), + created_at_micros: 10, + updated_at_micros: 11, + } + } + + fn pixel_art_project_resource_snapshot() -> crate::module_bindings::EditorProjectResourceSnapshot + { + crate::module_bindings::EditorProjectResourceSnapshot { + resource_id: "editor-resource-pixel".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + asset_object_id: Some("assetobj_pixel".to_string()), + image_src: "/api/assets/assetobj_pixel/content".to_string(), + object_key: Some("editor/pixel.png".to_string()), + width: 32, + height: 32, + source_type: "perfect-pixel".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: Some("pixel-art-snap-dialog-1".to_string()), + source_resource_id: Some("source-resource".to_string()), + asset_kind: Some("image".to_string()), + generation_inputs_json: Some(r#"{"source":"canvas"}"#.to_string()), + public_showcase_enabled: false, + created_at_micros: 10, + updated_at_micros: 11, + } + } + + fn pixel_art_asset_snapshot() -> crate::module_bindings::EditorAssetSnapshot { + crate::module_bindings::EditorAssetSnapshot { + asset_id: "editor-asset-pixel".to_string(), + folder_id: "folder-1".to_string(), + label: "Pixel result".to_string(), + asset_object_id: Some("assetobj_pixel".to_string()), + image_src: "/api/assets/assetobj_pixel/content".to_string(), + object_key: Some("editor/pixel.png".to_string()), + width: 32, + height: 32, + source_type: "perfect-pixel".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: Some("pixel-art-snap-dialog-1".to_string()), + asset_kind: Some("image".to_string()), + generation_inputs_json: Some(r#"{"source":"canvas"}"#.to_string()), + source_resource_id: Some("source-resource".to_string()), + public_showcase_enabled: Some(false), + created_at_micros: 10, + updated_at_micros: 11, + thumbnail_src: None, + generation_cost_mud_points: 0, + showcase_id: None, + showcase_review_status: None, + showcase_display_enabled: None, + showcase_like_count: None, + group_task_id: None, + } + } + + fn successful_pixel_art_persist_result( + status: crate::module_bindings::EditorPixelArtResultPersistStatus, + ) -> crate::module_bindings::EditorPixelArtResultPersistResult { + crate::module_bindings::EditorPixelArtResultPersistResult { + ok: true, + status: Some(status), + asset_object: Some(pixel_art_asset_object_snapshot()), + project_resource: Some(pixel_art_project_resource_snapshot()), + asset: Some(pixel_art_asset_snapshot()), + project: None, + error_message: None, + } + } + + #[test] + fn editor_pixel_art_persist_mapper_rejects_procedure_failure_before_snapshots() { + let mut result = empty_pixel_art_persist_result(false); + result.error_message = Some("事务提交失败".to_string()); + + let error = map_editor_pixel_art_result_persist_result(result) + .expect_err("ok=false 必须保留 procedure 失败"); + + assert!(matches!( + error, + SpacetimeClientError::Procedure(message) if message == "事务提交失败" + )); + } + + #[test] + fn editor_pixel_art_persist_mapper_rejects_missing_required_snapshot() { + let mut result = empty_pixel_art_persist_result(true); + result.status = Some(crate::module_bindings::EditorPixelArtResultPersistStatus::Applied); + + let error = map_editor_pixel_art_result_persist_result(result) + .expect_err("成功结果缺少 asset object 时必须拒绝"); + + assert!(matches!( + error, + SpacetimeClientError::Runtime(message) + if message == "完美像素持久化结果缺少 asset object" + )); + } + + #[test] + fn editor_pixel_art_persist_mapper_rejects_missing_status() { + let mut result = successful_pixel_art_persist_result( + crate::module_bindings::EditorPixelArtResultPersistStatus::Applied, + ); + result.status = None; + + let error = map_editor_pixel_art_result_persist_result(result) + .expect_err("成功结果缺少状态时必须拒绝"); + + assert!(matches!( + error, + SpacetimeClientError::Runtime(message) + if message == "完美像素持久化结果缺少状态" + )); + } + + #[test] + fn editor_pixel_art_persist_mapper_accepts_every_status_and_missing_project() { + let cases = [ + crate::module_bindings::EditorPixelArtResultPersistStatus::Applied, + crate::module_bindings::EditorPixelArtResultPersistStatus::DialogMissing, + crate::module_bindings::EditorPixelArtResultPersistStatus::AlreadyApplied, + ]; + + for binding_status in cases { + let mapped = map_editor_pixel_art_result_persist_result( + successful_pixel_art_persist_result(binding_status), + ) + .expect("project=None 仍应映射成功"); + + assert_eq!(mapped.asset_object.asset_object_id, "assetobj_pixel"); + assert_eq!(mapped.project_resource.resource_id, "editor-resource-pixel"); + assert_eq!(mapped.asset.asset_id, "editor-asset-pixel"); + assert!(mapped.project.is_none()); + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index e0100906a..9259c2b63 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -297,6 +297,13 @@ pub mod editor_generation_pricing_tier_type; pub mod editor_generation_runtime_identity_rotate_input_type; pub mod editor_generation_runtime_identity_rotation_table; pub mod editor_generation_runtime_identity_rotation_type; +pub mod editor_pixel_art_canvas_completion_input_type; +pub mod editor_pixel_art_canvas_placeholder_input_type; +pub mod editor_pixel_art_result_persist_input_type; +pub mod editor_pixel_art_result_persist_result_type; +pub mod editor_pixel_art_result_persist_status_type; +pub mod editor_pixel_art_result_preflight_input_type; +pub mod editor_pixel_art_result_preflight_result_type; pub mod editor_project_create_input_type; pub mod editor_project_delete_input_type; pub mod editor_project_delete_procedure_result_type; @@ -475,10 +482,12 @@ pub mod npc_relation_state_type; pub mod npc_stance_profile_type; pub mod npc_state_table; pub mod npc_state_type; +pub mod persist_editor_pixel_art_result_and_return_procedure; pub mod persist_editor_spritesheet_slice_batch_and_return_procedure; pub mod player_progression_grant_source_type; pub mod player_progression_table; pub mod player_progression_type; +pub mod preflight_editor_pixel_art_result_and_return_procedure; pub mod prepare_profile_recharge_refund_hold_and_return_procedure; pub mod preview_profile_recharge_refund_hold_and_return_procedure; pub mod profile_code_operation_table; @@ -1120,6 +1129,13 @@ pub use editor_generation_pricing_tier_type::EditorGenerationPricingTier; pub use editor_generation_runtime_identity_rotate_input_type::EditorGenerationRuntimeIdentityRotateInput; pub use editor_generation_runtime_identity_rotation_table::*; pub use editor_generation_runtime_identity_rotation_type::EditorGenerationRuntimeIdentityRotation; +pub use editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput; +pub use editor_pixel_art_canvas_placeholder_input_type::EditorPixelArtCanvasPlaceholderInput; +pub use editor_pixel_art_result_persist_input_type::EditorPixelArtResultPersistInput; +pub use editor_pixel_art_result_persist_result_type::EditorPixelArtResultPersistResult; +pub use editor_pixel_art_result_persist_status_type::EditorPixelArtResultPersistStatus; +pub use editor_pixel_art_result_preflight_input_type::EditorPixelArtResultPreflightInput; +pub use editor_pixel_art_result_preflight_result_type::EditorPixelArtResultPreflightResult; pub use editor_project_create_input_type::EditorProjectCreateInput; pub use editor_project_delete_input_type::EditorProjectDeleteInput; pub use editor_project_delete_procedure_result_type::EditorProjectDeleteProcedureResult; @@ -1298,10 +1314,12 @@ pub use npc_relation_state_type::NpcRelationState; pub use npc_stance_profile_type::NpcStanceProfile; pub use npc_state_table::*; pub use npc_state_type::NpcState; +pub use persist_editor_pixel_art_result_and_return_procedure::persist_editor_pixel_art_result_and_return; pub use persist_editor_spritesheet_slice_batch_and_return_procedure::persist_editor_spritesheet_slice_batch_and_return; pub use player_progression_grant_source_type::PlayerProgressionGrantSource; pub use player_progression_table::*; pub use player_progression_type::PlayerProgression; +pub use preflight_editor_pixel_art_result_and_return_procedure::preflight_editor_pixel_art_result_and_return; pub use prepare_profile_recharge_refund_hold_and_return_procedure::prepare_profile_recharge_refund_hold_and_return; pub use preview_profile_recharge_refund_hold_and_return_procedure::preview_profile_recharge_refund_hold_and_return; pub use profile_code_operation_table::*; @@ -5061,19 +5079,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle { /// either a [`DbConnection`] or an [`EventContext`] and operate on either. pub trait RemoteDbContext: __sdk::DbContext< - DbView = RemoteTables, - Reducers = RemoteReducers, - SubscriptionBuilder = __sdk::SubscriptionBuilder, -> + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, + > { } impl< - Ctx: __sdk::DbContext< + Ctx: __sdk::DbContext< DbView = RemoteTables, Reducers = RemoteReducers, SubscriptionBuilder = __sdk::SubscriptionBuilder, >, - > RemoteDbContext for Ctx +> RemoteDbContext for Ctx { } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_completion_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_completion_input_type.rs new file mode 100644 index 000000000..bf42b848c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_completion_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_pixel_art_canvas_placeholder_input_type::EditorPixelArtCanvasPlaceholderInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorPixelArtCanvasCompletionInput { + pub dialog_id: String, + pub title: String, + pub placeholder: EditorPixelArtCanvasPlaceholderInput, +} + +impl __sdk::InModule for EditorPixelArtCanvasCompletionInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_placeholder_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_placeholder_input_type.rs new file mode 100644 index 000000000..560b919f2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_placeholder_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorPixelArtCanvasPlaceholderInput { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub original_width: f64, + pub original_height: f64, +} + +impl __sdk::InModule for EditorPixelArtCanvasPlaceholderInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_input_type.rs new file mode 100644 index 000000000..9ee40e575 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_input_type.rs @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_upsert_input_type::AssetObjectUpsertInput; +use super::editor_asset_create_input_type::EditorAssetCreateInput; +use super::editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput; +use super::editor_project_resource_create_input_type::EditorProjectResourceCreateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorPixelArtResultPersistInput { + pub owner_user_id: String, + pub project_id: String, + pub operation_id: String, + pub operation_fingerprint: String, + pub asset_object: AssetObjectUpsertInput, + pub project_resource: EditorProjectResourceCreateInput, + pub asset: EditorAssetCreateInput, + pub canvas_completion: EditorPixelArtCanvasCompletionInput, + pub completed_at_micros: i64, +} + +impl __sdk::InModule for EditorPixelArtResultPersistInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_result_type.rs new file mode 100644 index 000000000..e9b04d65d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_result_type.rs @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; +use super::editor_asset_snapshot_type::EditorAssetSnapshot; +use super::editor_pixel_art_result_persist_status_type::EditorPixelArtResultPersistStatus; +use super::editor_project_resource_snapshot_type::EditorProjectResourceSnapshot; +use super::editor_project_snapshot_type::EditorProjectSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorPixelArtResultPersistResult { + pub ok: bool, + pub status: Option, + pub asset_object: Option, + pub project_resource: Option, + pub asset: Option, + pub project: Option, + pub error_message: Option, +} + +impl __sdk::InModule for EditorPixelArtResultPersistResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_status_type.rs new file mode 100644 index 000000000..942e61bf0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_status_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum EditorPixelArtResultPersistStatus { + Applied, + + DialogMissing, + + AlreadyApplied, +} + +impl __sdk::InModule for EditorPixelArtResultPersistStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_input_type.rs new file mode 100644 index 000000000..0699166f6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput; +use super::editor_project_resource_create_input_type::EditorProjectResourceCreateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorPixelArtResultPreflightInput { + pub owner_user_id: String, + pub project_id: String, + pub asset_folder_id: String, + pub project_resource: EditorProjectResourceCreateInput, + pub canvas_completion: EditorPixelArtCanvasCompletionInput, +} + +impl __sdk::InModule for EditorPixelArtResultPreflightInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_result_type.rs new file mode 100644 index 000000000..5b089e7ff --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_result_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorPixelArtResultPreflightResult { + pub ok: bool, + pub error_message: Option, +} + +impl __sdk::InModule for EditorPixelArtResultPreflightResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs new file mode 100644 index 000000000..c25ee2e1c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_pixel_art_result_persist_input_type::EditorPixelArtResultPersistInput; +use super::editor_pixel_art_result_persist_result_type::EditorPixelArtResultPersistResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PersistEditorPixelArtResultAndReturnArgs { + pub input: EditorPixelArtResultPersistInput, +} + +impl __sdk::InModule for PersistEditorPixelArtResultAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `persist_editor_pixel_art_result_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait persist_editor_pixel_art_result_and_return { + fn persist_editor_pixel_art_result_and_return(&self, input: EditorPixelArtResultPersistInput) { + self.persist_editor_pixel_art_result_and_return_then(input, |_, _| {}); + } + + fn persist_editor_pixel_art_result_and_return_then( + &self, + input: EditorPixelArtResultPersistInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl persist_editor_pixel_art_result_and_return for super::RemoteProcedures { + fn persist_editor_pixel_art_result_and_return_then( + &self, + input: EditorPixelArtResultPersistInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorPixelArtResultPersistResult>( + "persist_editor_pixel_art_result_and_return", + PersistEditorPixelArtResultAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs new file mode 100644 index 000000000..f38144b63 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_pixel_art_result_preflight_input_type::EditorPixelArtResultPreflightInput; +use super::editor_pixel_art_result_preflight_result_type::EditorPixelArtResultPreflightResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PreflightEditorPixelArtResultAndReturnArgs { + pub input: EditorPixelArtResultPreflightInput, +} + +impl __sdk::InModule for PreflightEditorPixelArtResultAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `preflight_editor_pixel_art_result_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait preflight_editor_pixel_art_result_and_return { + fn preflight_editor_pixel_art_result_and_return( + &self, + input: EditorPixelArtResultPreflightInput, + ) { + self.preflight_editor_pixel_art_result_and_return_then(input, |_, _| {}); + } + + fn preflight_editor_pixel_art_result_and_return_then( + &self, + input: EditorPixelArtResultPreflightInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl preflight_editor_pixel_art_result_and_return for super::RemoteProcedures { + fn preflight_editor_pixel_art_result_and_return_then( + &self, + input: EditorPixelArtResultPreflightInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorPixelArtResultPreflightResult>( + "preflight_editor_pixel_art_result_and_return", + PreflightEditorPixelArtResultAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 48fb89c49..25c30c134 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -897,6 +897,69 @@ pub struct EditorSpritesheetSliceBatchPersistResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtCanvasPlaceholderInput { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub original_width: f64, + pub original_height: f64, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtCanvasCompletionInput { + pub dialog_id: String, + pub title: String, + pub placeholder: EditorPixelArtCanvasPlaceholderInput, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtResultPreflightInput { + pub owner_user_id: String, + pub project_id: String, + pub asset_folder_id: String, + pub project_resource: EditorProjectResourceCreateInput, + pub canvas_completion: EditorPixelArtCanvasCompletionInput, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorPixelArtResultPreflightResult { + pub ok: bool, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtResultPersistInput { + pub owner_user_id: String, + pub project_id: String, + pub operation_id: String, + pub operation_fingerprint: String, + pub asset_object: AssetObjectUpsertInput, + pub project_resource: EditorProjectResourceCreateInput, + pub asset: EditorAssetCreateInput, + pub canvas_completion: EditorPixelArtCanvasCompletionInput, + pub completed_at_micros: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)] +pub enum EditorPixelArtResultPersistStatus { + Applied, + DialogMissing, + AlreadyApplied, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtResultPersistResult { + pub ok: bool, + pub status: Option, + pub asset_object: Option, + pub project_resource: Option, + pub asset: Option, + pub project: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorAssetUpdateInput { pub asset_id: String, @@ -1543,6 +1606,52 @@ pub fn persist_editor_spritesheet_slice_batch_and_return( } } +#[spacetimedb::procedure] +pub fn preflight_editor_pixel_art_result_and_return( + ctx: &mut ProcedureContext, + input: EditorPixelArtResultPreflightInput, +) -> EditorPixelArtResultPreflightResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| preflight_editor_pixel_art_result(tx, caller, input.clone())) { + Ok(()) => EditorPixelArtResultPreflightResult { + ok: true, + error_message: None, + }, + Err(message) => EditorPixelArtResultPreflightResult { + ok: false, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn persist_editor_pixel_art_result_and_return( + ctx: &mut ProcedureContext, + input: EditorPixelArtResultPersistInput, +) -> EditorPixelArtResultPersistResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| persist_editor_pixel_art_result(tx, caller, input.clone())) { + Ok(persisted) => EditorPixelArtResultPersistResult { + ok: true, + status: Some(persisted.status), + asset_object: Some(persisted.asset_object), + project_resource: Some(persisted.project_resource), + asset: Some(persisted.asset), + project: persisted.project, + error_message: None, + }, + Err(message) => EditorPixelArtResultPersistResult { + ok: false, + status: None, + asset_object: None, + project_resource: None, + asset: None, + project: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn update_editor_asset_and_return( ctx: &mut ProcedureContext, @@ -2130,6 +2239,43 @@ fn create_editor_project_resource( ctx: &ReducerContext, input: EditorProjectResourceCreateInput, ) -> Result { + let resource = prepare_editor_project_resource(ctx, input)?; + if let Some(existing_resource) = find_reusable_project_resource_for_input( + ctx, + resource.project_id.as_str(), + resource.owner_user_id.as_str(), + resource.source_resource_id.as_ref(), + resource.asset_kind.as_deref(), + resource.asset_object_id.as_ref(), + resource.object_key.as_ref(), + resource.image_src.as_str(), + ) { + return Ok(resource_snapshot_from_row(existing_resource)); + } + if ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource.resource_id) + .is_some() + { + return Err("画布资源已存在".to_string()); + } + let resource_id = resource.resource_id.clone(); + ctx.db.editor_project_resource().insert(resource); + + ctx.db + .editor_project_resource() + .resource_id() + .find(&resource_id) + .map(resource_snapshot_from_row) + .ok_or_else(|| "画布资源创建失败".to_string()) +} + +fn prepare_editor_project_resource( + ctx: &ReducerContext, + input: EditorProjectResourceCreateInput, +) -> Result { let resource_id = normalize_required(&input.resource_id, "editor_project_resource.resource_id")?; let project_id = normalize_required(&input.project_id, "editor_project_resource.project_id")?; @@ -2157,61 +2303,29 @@ fn create_editor_project_resource( let task_id = normalize_optional(input.task_id); let asset_kind = normalize_optional(input.asset_kind); let generation_inputs_json = normalize_optional(input.generation_inputs_json); - if let Some(existing_resource) = find_reusable_project_resource_for_input( - ctx, - project_id.as_str(), - owner_user_id.as_str(), - source_resource_id.as_ref(), - asset_kind.as_deref(), - asset_object_id.as_ref(), - object_key.as_ref(), - image_src.as_str(), - ) { - return Ok(resource_snapshot_from_row(existing_resource)); - } - if ctx - .db - .editor_project_resource() - .resource_id() - .find(&resource_id) - .is_some() - { - return Err("画布资源已存在".to_string()); - } - - let public_showcase_enabled = false; let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); - ctx.db - .editor_project_resource() - .insert(EditorProjectResource { - resource_id: resource_id.clone(), - project_id, - owner_user_id, - asset_object_id, - image_src, - object_key, - width: input.width, - height: input.height, - source_type, - prompt, - actual_prompt, - model, - provider, - task_id, - source_resource_id, - created_at: now, - updated_at: now, - asset_kind, - generation_inputs_json, - public_showcase_enabled, - }); - - ctx.db - .editor_project_resource() - .resource_id() - .find(&resource_id) - .map(resource_snapshot_from_row) - .ok_or_else(|| "画布资源创建失败".to_string()) + Ok(EditorProjectResource { + resource_id, + project_id, + owner_user_id, + asset_object_id, + image_src, + object_key, + width: input.width, + height: input.height, + source_type, + prompt, + actual_prompt, + model, + provider, + task_id, + source_resource_id, + created_at: now, + updated_at: now, + asset_kind, + generation_inputs_json, + public_showcase_enabled: false, + }) } fn repair_editor_project_resource_media( @@ -2536,6 +2650,888 @@ fn persist_editor_spritesheet_slice_batch( Ok(persisted_items) } +struct EditorPixelArtResultPersisted { + status: EditorPixelArtResultPersistStatus, + asset_object: AssetObjectUpsertSnapshot, + project_resource: EditorProjectResourceSnapshot, + asset: EditorAssetSnapshot, + project: Option, +} + +enum EditorPixelArtExistingRecords { + New, + Existing { + asset_object: AssetObjectUpsertSnapshot, + project_resource: EditorProjectResourceSnapshot, + asset: EditorAssetSnapshot, + }, +} + +enum EditorPixelArtCanvasCompletionPlan { + Apply { + canvas: EditorCanvas, + layers_json: String, + }, + DialogMissing, + AlreadyApplied, +} + +#[derive(Debug)] +enum EditorPixelArtCanvasLayoutPlan { + Apply(String), + DialogMissing, + AlreadyApplied, +} + +fn preflight_editor_pixel_art_result( + ctx: &ReducerContext, + caller: Identity, + input: EditorPixelArtResultPreflightInput, +) -> Result<(), String> { + require_editor_generation_runtime_service_identity(ctx, caller)?; + let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?; + let project_id = normalize_required(&input.project_id, "project_id")?; + let asset_folder_id = normalize_required(&input.asset_folder_id, "asset_folder_id")?; + require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?; + validate_editor_pixel_art_preflight_asset_folder( + ctx, + asset_folder_id.as_str(), + owner_user_id.as_str(), + )?; + + let candidate_resource = prepare_editor_project_resource(ctx, input.project_resource)?; + if candidate_resource.project_id != project_id + || candidate_resource.owner_user_id != owner_user_id + { + return Err("完美像素 preflight 项目资源与 owner 或项目不一致".to_string()); + } + if let Some(source_resource_id) = candidate_resource.source_resource_id.as_deref() { + let source_resource = + require_owned_project_resource(ctx, source_resource_id, owner_user_id.as_str())?; + if source_resource.project_id != project_id + || source_resource.resource_id == candidate_resource.resource_id + { + return Err("完美像素来源资源不属于同一 owner 与项目".to_string()); + } + } + + let candidate_snapshot = resource_snapshot_from_row(candidate_resource.clone()); + match plan_editor_pixel_art_canvas_completion( + ctx, + project_id.as_str(), + owner_user_id.as_str(), + &input.canvas_completion, + &candidate_snapshot, + )? { + EditorPixelArtCanvasCompletionPlan::Apply { + canvas, + layers_json, + } => validate_editor_pixel_art_planned_canvas_layout( + ctx, + &canvas, + project_id.as_str(), + owner_user_id.as_str(), + layers_json, + &candidate_resource, + ), + EditorPixelArtCanvasCompletionPlan::DialogMissing + | EditorPixelArtCanvasCompletionPlan::AlreadyApplied => Ok(()), + } +} + +fn validate_editor_pixel_art_preflight_asset_folder( + ctx: &ReducerContext, + folder_id: &str, + owner_user_id: &str, +) -> Result<(), String> { + let default_folder_id = default_asset_folder_id(owner_user_id); + if folder_id == EDITOR_ASSET_DEFAULT_FOLDER_ID || folder_id == default_folder_id { + if let Some(folder) = ctx + .db + .editor_asset_folder() + .folder_id() + .find(&default_folder_id) + && folder.owner_user_id != owner_user_id + { + return Err("默认素材文件夹不属于当前 owner".to_string()); + } + return Ok(()); + } + require_owned_asset_folder(ctx, folder_id, owner_user_id).map(|_| ()) +} + +fn persist_editor_pixel_art_result( + ctx: &ReducerContext, + caller: Identity, + mut input: EditorPixelArtResultPersistInput, +) -> Result { + require_editor_generation_runtime_service_identity(ctx, caller)?; + let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?; + let project_id = normalize_required(&input.project_id, "project_id")?; + let operation_id = normalize_required(&input.operation_id, "operation_id")?; + let operation_fingerprint = + normalize_required(&input.operation_fingerprint, "operation_fingerprint")?; + let dialog_id = normalize_required( + &input.canvas_completion.dialog_id, + "canvas_completion.dialog_id", + )?; + + if input.asset.folder_id.trim() == EDITOR_ASSET_DEFAULT_FOLDER_ID { + input.asset.folder_id = default_asset_folder_id(owner_user_id.as_str()); + } + validate_editor_pixel_art_result_input( + ctx, + &input, + owner_user_id.as_str(), + project_id.as_str(), + operation_id.as_str(), + operation_fingerprint.as_str(), + dialog_id.as_str(), + )?; + + let existing = load_editor_pixel_art_existing_records(ctx, &input)?; + let (asset_object, project_resource, asset) = match existing { + EditorPixelArtExistingRecords::Existing { + asset_object, + project_resource, + asset, + } => { + let project = resolve_editor_pixel_art_replay_project_snapshot( + ctx, + project_id.as_str(), + owner_user_id.as_str(), + &input.canvas_completion, + &project_resource, + ) + .map_err(editor_pixel_art_idempotency_conflict)?; + return Ok(EditorPixelArtResultPersisted { + status: EditorPixelArtResultPersistStatus::AlreadyApplied, + asset_object, + project_resource, + asset, + project, + }); + } + EditorPixelArtExistingRecords::New => { + let asset_object = + crate::asset_metadata::upsert_asset_object(ctx, input.asset_object.clone())?; + let project_resource = + create_editor_project_resource(ctx, input.project_resource.clone())?; + if project_resource.resource_id.trim() != input.project_resource.resource_id.trim() { + return Err("完美像素项目资源未按稳定幂等 ID 创建".to_string()); + } + let asset = create_editor_asset(ctx, caller, input.asset.clone())?; + if asset.asset_id.trim() != input.asset.asset_id.trim() { + return Err("完美像素账号素材未按稳定幂等 ID 创建".to_string()); + } + (asset_object, project_resource, asset) + } + }; + + let completion_plan = plan_editor_pixel_art_canvas_completion( + ctx, + project_id.as_str(), + owner_user_id.as_str(), + &input.canvas_completion, + &project_resource, + ) + .map_err(editor_pixel_art_idempotency_conflict)?; + let (status, project) = match completion_plan { + EditorPixelArtCanvasCompletionPlan::DialogMissing => { + (EditorPixelArtResultPersistStatus::DialogMissing, None) + } + EditorPixelArtCanvasCompletionPlan::AlreadyApplied => { + return Err(editor_pixel_art_idempotency_conflict( + "画布已有稳定结果,但对应 object/resource/asset 记录此前不存在", + )); + } + EditorPixelArtCanvasCompletionPlan::Apply { + canvas, + layers_json, + } => { + let persisted_resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&project_resource.resource_id) + .ok_or_else(|| "完美像素项目资源不存在,拒绝完成画布".to_string())?; + // 中文注释:preflight 不持有锁或 reservation。PUT 之后进入最终原子事务时, + // 必须对当下的画布、迁移记录和 2 MiB / 512 KiB 布局门禁完整重验,不能把 + // PUT 前的只读结论当成提交凭证。 + validate_editor_pixel_art_planned_canvas_layout( + ctx, + &canvas, + project_id.as_str(), + owner_user_id.as_str(), + layers_json.clone(), + &persisted_resource, + )?; + persist_editor_project_layout_v2( + ctx, + EditorProjectLayoutSaveV2Input { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + viewport: EditorProjectViewportSnapshot { + x: canvas.viewport_x, + y: canvas.viewport_y, + scale: canvas.viewport_scale, + }, + layers_json, + expected_revision: canvas.revision, + updated_at_micros: input.completed_at_micros, + }, + )?; + ( + EditorPixelArtResultPersistStatus::Applied, + Some(build_project_snapshot(ctx, project_id.as_str())?), + ) + } + }; + + Ok(EditorPixelArtResultPersisted { + status, + asset_object, + project_resource, + asset, + project, + }) +} + +fn validate_editor_pixel_art_result_input( + ctx: &ReducerContext, + input: &EditorPixelArtResultPersistInput, + owner_user_id: &str, + project_id: &str, + operation_id: &str, + operation_fingerprint: &str, + dialog_id: &str, +) -> Result<(), String> { + require_owned_project(ctx, project_id, owner_user_id)?; + let asset_object_id = normalize_required( + &input.asset_object.asset_object_id, + "asset_object.asset_object_id", + )?; + let object_key = normalize_required(&input.asset_object.object_key, "asset_object.object_key")?; + let resource_id = normalize_required( + &input.project_resource.resource_id, + "project_resource.resource_id", + )?; + let asset_id = normalize_required(&input.asset.asset_id, "asset.asset_id")?; + let task_id = normalize_optional(input.asset_object.source_job_id.clone()) + .ok_or_else(|| "完美像素 asset_object 缺少稳定 task_id".to_string())?; + let resource_task_id = normalize_optional(input.project_resource.task_id.clone()); + let asset_task_id = normalize_optional(input.asset.task_id.clone()); + let resource_asset_kind = normalize_optional(input.project_resource.asset_kind.clone()); + let asset_asset_kind = normalize_optional(input.asset.asset_kind.clone()); + let expected_task_id = format!("pixel-art-snap-{operation_id}"); + let expected_asset_object_id = editor_pixel_art_stable_record_id( + owner_user_id, + project_id, + operation_id, + "asset-object", + "assetobj_", + ); + let expected_resource_id = editor_pixel_art_stable_record_id( + owner_user_id, + project_id, + operation_id, + "project-resource", + "editor-resource-", + ); + let expected_asset_id = editor_pixel_art_stable_record_id( + owner_user_id, + project_id, + operation_id, + "asset", + "editor-asset-", + ); + + if operation_id != dialog_id + || operation_id != input.operation_id.trim() + || dialog_id != input.canvas_completion.dialog_id.trim() + { + return Err("完美像素 operation_id 必须等于规范化 dialog_id".to_string()); + } + if operation_id.contains('\0') || dialog_id.contains('\0') { + return Err("完美像素 operation_id 与 dialog_id 不得包含 NUL".to_string()); + } + if operation_fingerprint.len() != 64 + || !operation_fingerprint + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("完美像素 operation_fingerprint 必须是 64 位小写十六进制".to_string()); + } + if task_id != expected_task_id + || asset_object_id != expected_asset_object_id + || resource_id != expected_resource_id + || asset_id != expected_asset_id + { + return Err("完美像素 task/object/resource/asset 未使用稳定 operation 身份".to_string()); + } + if input.asset_object.owner_user_id.as_deref().map(str::trim) != Some(owner_user_id) + || input.asset_object.profile_id.is_some() + || input.asset_object.access_policy != AssetObjectAccessPolicy::Private + || input.asset_object.asset_kind.trim() != "editor_pixel_art_snap" + || input.asset_object.content_length == 0 + || !input + .asset_object + .content_type + .as_deref() + .is_some_and(|value| value.trim().eq_ignore_ascii_case("image/png")) + { + return Err("完美像素 asset_object 的 owner、访问策略或媒体类型不合法".to_string()); + } + if !object_key.contains(operation_fingerprint) { + return Err("完美像素 object_key 未包含 operation_fingerprint".to_string()); + } + if input.asset_object.entity_id.as_deref().map(str::trim) != Some(task_id.as_str()) { + return Err("完美像素 asset_object.entity_id 必须等于稳定 task_id".to_string()); + } + if input.project_resource.project_id.trim() != project_id + || input.project_resource.owner_user_id.trim() != owner_user_id + || normalize_optional(input.project_resource.asset_object_id.clone()).as_deref() + != Some(asset_object_id.as_str()) + || normalize_optional(input.project_resource.object_key.clone()).as_deref() + != Some(object_key.as_str()) + || normalize_media_ref(input.project_resource.image_src.as_str()) != object_key + || input.project_resource.source_type.trim() != "generated" + || resource_task_id.as_deref() != Some(task_id.as_str()) + { + return Err("完美像素项目资源与对象的 owner、媒体或任务归属不一致".to_string()); + } + if input.asset.owner_user_id.trim() != owner_user_id + || normalize_optional(input.asset.asset_object_id.clone()).as_deref() + != Some(asset_object_id.as_str()) + || normalize_optional(input.asset.object_key.clone()).as_deref() + != Some(object_key.as_str()) + || normalize_media_ref(input.asset.image_src.as_str()) != object_key + || input.asset.source_type.trim() != "generated" + || asset_task_id.as_deref() != Some(task_id.as_str()) + || normalize_optional(input.asset.source_resource_id.clone()).as_deref() + != Some(resource_id.as_str()) + { + return Err("完美像素账号素材与对象、项目资源的 owner、媒体或任务归属不一致".to_string()); + } + if resource_asset_kind != asset_asset_kind + || normalize_optional(input.project_resource.prompt.clone()) + != normalize_optional(input.asset.prompt.clone()) + || normalize_optional(input.project_resource.actual_prompt.clone()) + != normalize_optional(input.asset.actual_prompt.clone()) + || normalize_optional(input.project_resource.model.clone()) + != normalize_optional(input.asset.model.clone()) + || normalize_optional(input.project_resource.provider.clone()) + != normalize_optional(input.asset.provider.clone()) + || normalize_optional(input.project_resource.generation_inputs_json.clone()) + != normalize_optional(input.asset.generation_inputs_json.clone()) + || input.project_resource.width != input.asset.width + || input.project_resource.height != input.asset.height + || input.asset.generation_cost_mud_points != 0 + || input.asset.group_task_id.is_some() + || input.asset.group_task_expected_asset_count.is_some() + { + return Err("完美像素项目资源与账号素材的内容不一致".to_string()); + } + if input.asset_object.updated_at_micros != input.completed_at_micros + || input.project_resource.updated_at_micros != input.completed_at_micros + || input.asset.now_micros != input.completed_at_micros + { + return Err("完美像素持久化时间必须使用同一 completed_at_micros".to_string()); + } + normalize_required(&input.canvas_completion.title, "canvas_completion.title")?; + validate_editor_pixel_art_placeholder(&input.canvas_completion.placeholder)?; + normalize_required(&input.asset.folder_id, "asset.folder_id")?; + normalize_required(&asset_id, "asset.asset_id")?; + + if let Some(source_resource_id) = + normalize_optional(input.project_resource.source_resource_id.clone()) + { + let source_resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&source_resource_id) + .ok_or_else(|| "完美像素来源项目资源不存在".to_string())?; + if source_resource.resource_id == resource_id + || source_resource.owner_user_id != owner_user_id + || source_resource.project_id != project_id + { + return Err("完美像素来源资源不属于同一 owner 与项目".to_string()); + } + } + + Ok(()) +} + +fn editor_pixel_art_stable_record_id( + owner_user_id: &str, + project_id: &str, + operation_id: &str, + record_kind: &str, + prefix: &str, +) -> String { + let digest = Sha256::digest( + format!( + "editor-pixel-art-result-v1\0{owner_user_id}\0{project_id}\0{operation_id}\0{record_kind}" + ) + .as_bytes(), + ); + let suffix = digest + .iter() + .take(16) + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("{prefix}{suffix}") +} + +fn editor_pixel_art_idempotency_conflict(reason: impl AsRef) -> String { + format!("完美像素幂等冲突:{}", reason.as_ref()) +} + +fn validate_editor_pixel_art_placeholder( + placeholder: &EditorPixelArtCanvasPlaceholderInput, +) -> Result<(), String> { + if !placeholder.x.is_finite() + || !placeholder.y.is_finite() + || !placeholder.width.is_finite() + || !placeholder.height.is_finite() + || !placeholder.original_width.is_finite() + || !placeholder.original_height.is_finite() + || placeholder.width <= 0.0 + || placeholder.height <= 0.0 + || placeholder.original_width <= 0.0 + || placeholder.original_height <= 0.0 + { + return Err("完美像素 canvas placeholder 坐标或尺寸不合法".to_string()); + } + Ok(()) +} + +fn load_editor_pixel_art_existing_records( + ctx: &ReducerContext, + input: &EditorPixelArtResultPersistInput, +) -> Result { + let candidate = &input.asset_object; + let by_id = crate::asset_metadata::find_asset_object_snapshot_by_id( + ctx, + candidate.asset_object_id.as_str(), + ); + let by_location = crate::asset_metadata::find_asset_object_by_location( + ctx, + &AssetObjectLocationInput { + bucket: candidate.bucket.clone(), + object_key: candidate.object_key.clone(), + }, + ) + .map_err(|message| { + if message.contains("重复记录") { + editor_pixel_art_idempotency_conflict(message) + } else { + message + } + })?; + if let (Some(by_id), Some(by_location)) = (&by_id, &by_location) + && by_id.asset_object_id != by_location.asset_object_id + { + return Err(editor_pixel_art_idempotency_conflict( + "对象位置已由其他 asset_object_id 占用", + )); + } + let asset_object = by_id.or(by_location); + if let Some(existing) = &asset_object + && !editor_pixel_art_asset_object_matches(existing, candidate) + { + return Err("完美像素 asset_object 幂等键已被其他内容占用".to_string()); + } + + let resource_id = input.project_resource.resource_id.trim().to_string(); + let project_resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource_id); + if let Some(existing) = &project_resource + && !editor_pixel_art_project_resource_matches(existing, &input.project_resource) + { + return Err("完美像素 project_resource 幂等键已被其他内容占用".to_string()); + } + let asset_id = input.asset.asset_id.trim().to_string(); + let asset = ctx.db.editor_asset().asset_id().find(&asset_id); + if let Some(existing) = &asset + && !editor_pixel_art_asset_matches(existing, &input.asset) + { + return Err("完美像素 editor_asset 幂等键已被其他内容占用".to_string()); + } + + match ( + asset_object, + project_resource.map(resource_snapshot_from_row), + asset.map(|row| asset_snapshot_from_row(ctx, row)), + ) { + (None, None, None) => Ok(EditorPixelArtExistingRecords::New), + (Some(asset_object), Some(project_resource), Some(asset)) => { + Ok(EditorPixelArtExistingRecords::Existing { + asset_object, + project_resource, + asset, + }) + } + _ => Err("完美像素幂等记录不完整,拒绝补写部分状态".to_string()), + } +} + +fn editor_pixel_art_asset_object_matches( + existing: &AssetObjectUpsertSnapshot, + input: &AssetObjectUpsertInput, +) -> bool { + existing.asset_object_id.trim() == input.asset_object_id.trim() + && existing.bucket.trim() == input.bucket.trim() + && existing.object_key.trim() == input.object_key.trim() + && existing.access_policy == input.access_policy + && normalize_optional(existing.content_type.clone()) + == normalize_optional(input.content_type.clone()) + && existing.content_length == input.content_length + && normalize_optional(existing.content_hash.clone()) + == normalize_optional(input.content_hash.clone()) + && existing.version == input.version + && normalize_optional(existing.source_job_id.clone()) + == normalize_optional(input.source_job_id.clone()) + && normalize_optional(existing.owner_user_id.clone()) + == normalize_optional(input.owner_user_id.clone()) + && normalize_optional(existing.profile_id.clone()) + == normalize_optional(input.profile_id.clone()) + && normalize_optional(existing.entity_id.clone()) + == normalize_optional(input.entity_id.clone()) + && existing.asset_kind.trim() == input.asset_kind.trim() +} + +fn editor_pixel_art_project_resource_matches( + existing: &EditorProjectResource, + input: &EditorProjectResourceCreateInput, +) -> bool { + existing.resource_id.trim() == input.resource_id.trim() + && existing.project_id.trim() == input.project_id.trim() + && existing.owner_user_id.trim() == input.owner_user_id.trim() + && existing.asset_object_id == normalize_optional(input.asset_object_id.clone()) + && existing.image_src.trim() == input.image_src.trim() + && existing.object_key == normalize_optional(input.object_key.clone()) + && existing.width == input.width + && existing.height == input.height + && existing.source_type.trim() == input.source_type.trim() + && existing.prompt == normalize_optional(input.prompt.clone()) + && existing.actual_prompt == normalize_optional(input.actual_prompt.clone()) + && existing.model == normalize_optional(input.model.clone()) + && existing.provider == normalize_optional(input.provider.clone()) + && existing.task_id == normalize_optional(input.task_id.clone()) + && existing.source_resource_id == normalize_optional(input.source_resource_id.clone()) + && existing.asset_kind == normalize_optional(input.asset_kind.clone()) + && existing.generation_inputs_json + == normalize_optional(input.generation_inputs_json.clone()) + && !existing.public_showcase_enabled +} + +fn editor_pixel_art_asset_matches(existing: &EditorAsset, input: &EditorAssetCreateInput) -> bool { + existing.asset_id.trim() == input.asset_id.trim() + && existing.owner_user_id.trim() == input.owner_user_id.trim() + && existing.folder_id.trim() == input.folder_id.trim() + && existing.label == normalize_asset_label(&input.label) + && existing.asset_object_id == normalize_optional(input.asset_object_id.clone()) + && existing.image_src.trim() == input.image_src.trim() + && existing.object_key == normalize_optional(input.object_key.clone()) + && existing.width == input.width + && existing.height == input.height + && existing.source_type.trim() == input.source_type.trim() + && existing.prompt == normalize_optional(input.prompt.clone()) + && existing.actual_prompt == normalize_optional(input.actual_prompt.clone()) + && existing.model == normalize_optional(input.model.clone()) + && existing.provider == normalize_optional(input.provider.clone()) + && existing.task_id == normalize_optional(input.task_id.clone()) + && existing.asset_kind == normalize_optional(input.asset_kind.clone()) + && existing.generation_inputs_json + == normalize_optional(input.generation_inputs_json.clone()) + && existing.source_resource_id == normalize_optional(input.source_resource_id.clone()) + && existing.thumbnail_src == normalize_optional(input.thumbnail_src.clone()) + && existing.generation_cost_mud_points == input.generation_cost_mud_points + && existing.group_task_id == normalize_optional(input.group_task_id.clone()) + && existing.group_task_expected_asset_count == input.group_task_expected_asset_count +} + +fn resolve_editor_pixel_art_replay_project_snapshot( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + completion: &EditorPixelArtCanvasCompletionInput, + resource: &EditorProjectResourceSnapshot, +) -> Result, String> { + require_owned_project(ctx, project_id, owner_user_id)?; + let Some(canvas) = ctx + .db + .editor_canvas() + .canvas_id() + .find(&default_canvas_id(project_id)) + else { + return Ok(None); + }; + if canvas.owner_user_id != owner_user_id || canvas.project_id != project_id { + return Err("完美像素画布不属于当前 owner 与项目".to_string()); + } + let layers_json = + if canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + build_structured_canvas_layout_json(ctx, &canvas)? + } else { + canvas.layers_json + }; + if !validate_editor_pixel_art_canvas_replay(layers_json.as_str(), completion, resource)? { + return Ok(None); + } + build_project_snapshot(ctx, project_id).map(Some) +} + +fn validate_editor_pixel_art_canvas_replay( + layers_json: &str, + completion: &EditorPixelArtCanvasCompletionInput, + resource: &EditorProjectResourceSnapshot, +) -> Result { + match plan_editor_pixel_art_canvas_layout(layers_json, completion, resource)? { + EditorPixelArtCanvasLayoutPlan::DialogMissing => Ok(false), + EditorPixelArtCanvasLayoutPlan::AlreadyApplied => Ok(true), + EditorPixelArtCanvasLayoutPlan::Apply(_) => Err( + "稳定 object/resource/asset 已存在,但画布 dialog 尚未由该 operation 收口".to_string(), + ), + } +} + +fn plan_editor_pixel_art_canvas_completion( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + completion: &EditorPixelArtCanvasCompletionInput, + resource: &EditorProjectResourceSnapshot, +) -> Result { + require_owned_project(ctx, project_id, owner_user_id)?; + let Some(canvas) = ctx + .db + .editor_canvas() + .canvas_id() + .find(&default_canvas_id(project_id)) + else { + return Ok(EditorPixelArtCanvasCompletionPlan::DialogMissing); + }; + if canvas.owner_user_id != owner_user_id || canvas.project_id != project_id { + return Err("完美像素画布不属于当前 owner 与项目".to_string()); + } + let current_layers_json = + if canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + build_structured_canvas_layout_json(ctx, &canvas)? + } else { + canvas.layers_json.clone() + }; + match plan_editor_pixel_art_canvas_layout(current_layers_json.as_str(), completion, resource)? { + EditorPixelArtCanvasLayoutPlan::Apply(layers_json) => { + Ok(EditorPixelArtCanvasCompletionPlan::Apply { + canvas, + layers_json, + }) + } + EditorPixelArtCanvasLayoutPlan::DialogMissing => { + Ok(EditorPixelArtCanvasCompletionPlan::DialogMissing) + } + EditorPixelArtCanvasLayoutPlan::AlreadyApplied => { + Ok(EditorPixelArtCanvasCompletionPlan::AlreadyApplied) + } + } +} + +fn plan_editor_pixel_art_canvas_layout( + current_layers_json: &str, + completion: &EditorPixelArtCanvasCompletionInput, + resource: &EditorProjectResourceSnapshot, +) -> Result { + let mut layout = serde_json::from_str::(current_layers_json) + .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + let items = layout + .as_array_mut() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + let dialog_id = completion.dialog_id.trim(); + let dialog_indexes = items + .iter() + .enumerate() + .filter_map(|(index, item)| { + let dialog = canvas_generation_dialog(item)?; + (canvas_generation_dialog_id(dialog).as_deref() == Some(dialog_id)).then_some(index) + }) + .collect::>(); + if dialog_indexes.is_empty() { + return Ok(EditorPixelArtCanvasLayoutPlan::DialogMissing); + } + if dialog_indexes.len() != 1 { + return Err("完美像素画布包含重复 dialog_id".to_string()); + } + let dialog_index = dialog_indexes[0]; + let dialog = canvas_generation_dialog(&items[dialog_index]) + .cloned() + .ok_or_else(|| "完美像素画布占位缺少 dialog".to_string())?; + let generated_layer_id = format!("layer-editor-resource-{}", resource.resource_id); + let matching_layer_indexes = items + .iter() + .enumerate() + .filter_map(|(index, item)| { + (canvas_layout_layer_id(item).as_deref() == Some(generated_layer_id.as_str())) + .then_some(index) + }) + .collect::>(); + if matching_layer_indexes.len() > 1 { + return Err("完美像素画布包含重复结果图层".to_string()); + } + + let dialog_status = canvas_generation_dialog_status(&dialog); + let completed_layer_id = canvas_generation_dialog_generated_layer_id(&dialog); + if dialog_status == Some("generating") && completed_layer_id.is_some() { + return Err("完美像素旧结果不得覆盖同一 dialog 的在途重试".to_string()); + } + if let Some(completed_layer_id) = completed_layer_id { + if completed_layer_id != generated_layer_id { + return Err("完美像素 dialog 已由其他结果完成".to_string()); + } + let existing_layer = matching_layer_indexes + .first() + .and_then(|index| items.get(*index)) + .ok_or_else(|| "完美像素 dialog 指向的结果图层不存在".to_string())?; + if !editor_pixel_art_canvas_layer_matches(existing_layer, resource) { + return Err("完美像素已完成图层与持久化资源不一致".to_string()); + } + return Ok(EditorPixelArtCanvasLayoutPlan::AlreadyApplied); + } + if !matching_layer_indexes.is_empty() { + return Err("完美像素稳定 layer_id 已被未关联图层占用".to_string()); + } + + let placeholder = editor_pixel_art_dialog_placeholder(&dialog) + .filter(|placeholder| validate_editor_pixel_art_placeholder(placeholder).is_ok()) + .unwrap_or_else(|| completion.placeholder.clone()); + let next_z_index = items + .iter() + .filter_map(|item| item.get("zIndex").and_then(JsonValue::as_i64)) + .max() + .unwrap_or(0) + + 1; + let result_layer = build_editor_pixel_art_canvas_layer( + completion, + &placeholder, + resource, + generated_layer_id.as_str(), + next_z_index, + )?; + items.push(result_layer); + let dialog_object = items + .get_mut(dialog_index) + .and_then(|item| item.get_mut("dialog")) + .and_then(JsonValue::as_object_mut) + .ok_or_else(|| "完美像素画布占位缺少 dialog".to_string())?; + dialog_object.insert("status".to_string(), JsonValue::String("idle".to_string())); + dialog_object.insert("composerOpen".to_string(), JsonValue::Bool(false)); + dialog_object.insert( + "generatedLayerId".to_string(), + JsonValue::String(generated_layer_id), + ); + dialog_object.remove("errorMessage"); + + serde_json::to_string(&layout) + .map(EditorPixelArtCanvasLayoutPlan::Apply) + .map_err(|_| "完美像素完成后的画布布局无法序列化".to_string()) +} + +fn editor_pixel_art_dialog_placeholder( + dialog: &JsonValue, +) -> Option { + let placeholder = dialog.get("placeholder")?.as_object()?; + Some(EditorPixelArtCanvasPlaceholderInput { + x: json_optional_number(placeholder.get("x"))?, + y: json_optional_number(placeholder.get("y"))?, + width: json_optional_number(placeholder.get("width"))?, + height: json_optional_number(placeholder.get("height"))?, + original_width: json_optional_number(placeholder.get("originalWidth"))?, + original_height: json_optional_number(placeholder.get("originalHeight"))?, + }) +} + +fn build_editor_pixel_art_canvas_layer( + completion: &EditorPixelArtCanvasCompletionInput, + placeholder: &EditorPixelArtCanvasPlaceholderInput, + resource: &EditorProjectResourceSnapshot, + generated_layer_id: &str, + z_index: i64, +) -> Result { + let original_width = + positive_editor_pixel_art_dimension(f64::from(resource.width), placeholder.original_width); + let original_height = positive_editor_pixel_art_dimension( + f64::from(resource.height), + placeholder.original_height, + ); + let width = positive_editor_pixel_art_dimension(original_width, placeholder.width); + let height = positive_editor_pixel_art_dimension(original_height, placeholder.height); + let placeholder_width = positive_editor_pixel_art_dimension(placeholder.width, width); + let placeholder_height = positive_editor_pixel_art_dimension(placeholder.height, height); + let x = placeholder.x + placeholder_width / 2.0 - width / 2.0; + let y = placeholder.y + placeholder_height / 2.0 - height / 2.0; + let generation_inputs = resource + .generation_inputs_json + .as_deref() + .map(serde_json::from_str::) + .transpose() + .map_err(|_| "完美像素项目资源 generation_inputs_json 不是合法 JSON".to_string())? + .unwrap_or(JsonValue::Null); + + Ok(serde_json::json!({ + "layerId": generated_layer_id, + "resourceId": resource.resource_id, + "title": completion.title.trim(), + "src": resource.image_src, + "x": x, + "y": y, + "width": width, + "height": height, + "originalWidth": original_width, + "originalHeight": original_height, + "zIndex": z_index, + "sourceType": "generated", + "mediaType": "image", + "prompt": resource.prompt, + "actualPrompt": resource.actual_prompt, + "model": resource.model, + "provider": resource.provider, + "taskId": resource.task_id, + "objectKey": resource.object_key, + "assetObjectId": resource.asset_object_id, + "sourceResourceId": resource.source_resource_id, + "assetKind": resource.asset_kind, + "generationInputs": generation_inputs, + })) +} + +fn editor_pixel_art_canvas_layer_matches( + item: &JsonValue, + resource: &EditorProjectResourceSnapshot, +) -> bool { + let expected_layer_id = format!("layer-editor-resource-{}", resource.resource_id); + item.get("layerId").and_then(JsonValue::as_str) == Some(expected_layer_id.as_str()) + && item.get("resourceId").and_then(JsonValue::as_str) == Some(resource.resource_id.as_str()) + && item.get("objectKey").and_then(JsonValue::as_str) == resource.object_key.as_deref() + && item.get("assetObjectId").and_then(JsonValue::as_str) + == resource.asset_object_id.as_deref() + && item.get("taskId").and_then(JsonValue::as_str) == resource.task_id.as_deref() +} + +fn positive_editor_pixel_art_dimension(value: f64, fallback: f64) -> f64 { + if value.is_finite() && value > 0.0 { + value.round().max(1.0) + } else if fallback.is_finite() && fallback > 0.0 { + fallback.round().max(1.0) + } else { + 1.0 + } +} + fn create_or_reuse_editor_spritesheet_asset( ctx: &ReducerContext, caller: Identity, @@ -6109,18 +7105,13 @@ fn normalize_structured_canvas_layer_against_resource( resource.actual_prompt.as_deref(), layer.layer_id.as_str(), )?; - take_matching_optional_resource_string( - &mut item, - "model", - resource.model.as_deref(), - layer.layer_id.as_str(), - )?; - take_matching_optional_resource_string( - &mut item, - "provider", - resource.provider.as_deref(), - layer.layer_id.as_str(), - )?; + // 中文注释:`model` / `provider` 不做判等,一律以资源行为准。owner 读边界会脱敏内部处理 + // 模型(`model`)并无条件省略 `provider`,客户端因此拿不到权威值,这条判等对正常路径是 + // 不可满足的——留着只会让「读到什么就回写什么」的客户端整次保存 400。两者都属于纯丢弃 + // 字段,判等通过与否都不写回资源行(不同于会合并回资源的 assetKind / generationInputs), + // 因此丢弃客户端值不影响任何持久化状态。 + item.remove("model"); + item.remove("provider"); take_matching_optional_resource_string( &mut item, "taskId", @@ -7767,6 +8758,23 @@ fn validate_repaired_editor_canvas_layout_resources( owner_user_id: &str, planned_resources: &[EditorProjectResource], ) -> Result<(), String> { + normalize_editor_canvas_layout_with_planned_resources( + ctx, + layers_json, + project_id, + owner_user_id, + planned_resources, + ) + .map(|_| ()) +} + +fn normalize_editor_canvas_layout_with_planned_resources( + ctx: &ReducerContext, + layers_json: &str, + project_id: &str, + owner_user_id: &str, + planned_resources: &[EditorProjectResource], +) -> Result { let mut layout = parse_structured_canvas_layout(layers_json)?; for layer in &mut layout.layers { let stored = ctx @@ -7797,7 +8805,49 @@ fn validate_repaired_editor_canvas_layout_resources( } } } - Ok(()) + normalize_layout_json( + serialize_structured_canvas_layout(&layout) + .map_err(|_| "图片画布布局无法序列化".to_string())?, + ) +} + +fn validate_editor_pixel_art_planned_canvas_layout( + ctx: &ReducerContext, + canvas: &EditorCanvas, + project_id: &str, + owner_user_id: &str, + layers_json: String, + candidate_resource: &EditorProjectResource, +) -> Result<(), String> { + if canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + let active_migration = ctx + .db + .editor_canvas_layout_migration() + .canvas_id() + .find(&canvas.canvas_id) + .filter(|migration| migration.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE) + .ok_or_else(|| "结构化图片画布缺少 active 迁移记录,拒绝保存".to_string())?; + let current_structured_json = build_structured_canvas_layout_json(ctx, canvas)?; + let current_structured_hash = canonical_layout_sha256(current_structured_json.as_str())?; + let current_structured_integrity = + canvas_layout_integrity(current_structured_json.as_str())?; + verify_migration_layout( + &active_migration, + canvas.revision, + current_structured_hash.as_str(), + ¤t_structured_integrity, + )?; + } + normalize_layout_json(layers_json).and_then(|layers_json| { + normalize_editor_canvas_layout_with_planned_resources( + ctx, + layers_json.as_str(), + project_id, + owner_user_id, + std::slice::from_ref(candidate_resource), + ) + .map(|_| ()) + }) } fn backfill_editor_canvas_layout( @@ -8747,6 +9797,267 @@ mod tests { } } + fn pixel_art_project_resource() -> EditorProjectResourceSnapshot { + EditorProjectResourceSnapshot { + resource_id: "editor-resource-pixel".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "owner-1".to_string(), + asset_object_id: Some("assetobj_pixel".to_string()), + image_src: "/generated/editor/pixel/result.png".to_string(), + object_key: Some("generated/editor/pixel/result.png".to_string()), + width: 32, + height: 16, + source_type: "generated".to_string(), + prompt: Some("完美像素".to_string()), + actual_prompt: None, + model: Some("Perfect Pixel".to_string()), + provider: Some("Genarrative".to_string()), + task_id: Some("pixel-art-snap-dialog-1".to_string()), + source_resource_id: Some("source-resource".to_string()), + asset_kind: Some("image".to_string()), + generation_inputs_json: Some("{\"source\":\"canvas\"}".to_string()), + public_showcase_enabled: false, + created_at_micros: 1_000_000, + updated_at_micros: 1_000_000, + } + } + + fn pixel_art_canvas_completion() -> EditorPixelArtCanvasCompletionInput { + EditorPixelArtCanvasCompletionInput { + dialog_id: "dialog-1".to_string(), + title: "结果".to_string(), + placeholder: EditorPixelArtCanvasPlaceholderInput { + x: 100.0, + y: 50.0, + width: 200.0, + height: 100.0, + original_width: 200.0, + original_height: 100.0, + }, + } + } + + #[test] + fn pixel_art_stable_record_ids_are_domain_separated() { + assert_eq!( + editor_pixel_art_stable_record_id( + "owner-1", + "project-1", + "dialog-1", + "asset-object", + "assetobj_", + ), + "assetobj_8a264e1086ee3d6878d753aec254e0a5" + ); + assert_eq!( + editor_pixel_art_stable_record_id( + "owner-1", + "project-1", + "dialog-1", + "project-resource", + "editor-resource-", + ), + "editor-resource-9eee84b77e8828ca8f5042919198ac1c" + ); + assert_eq!( + editor_pixel_art_stable_record_id( + "owner-1", + "project-1", + "dialog-1", + "asset", + "editor-asset-", + ), + "editor-asset-c7e7e66538f4613226e68001b8408104" + ); + } + + #[test] + fn pixel_art_preflight_is_read_only_and_final_transaction_revalidates() { + let source = include_str!("editor_project_storage.rs"); + let preflight_start = source + .find("fn preflight_editor_pixel_art_result(\n") + .expect("preflight implementation"); + let preflight_end = source[preflight_start..] + .find("fn validate_editor_pixel_art_preflight_asset_folder(") + .map(|offset| preflight_start + offset) + .expect("preflight folder validation boundary"); + let preflight = &source[preflight_start..preflight_end]; + for required in [ + "require_editor_generation_runtime_service_identity", + "validate_editor_pixel_art_preflight_asset_folder", + "prepare_editor_project_resource", + "plan_editor_pixel_art_canvas_completion", + "validate_editor_pixel_art_planned_canvas_layout", + ] { + assert!( + preflight.contains(required), + "preflight must retain {required}" + ); + } + for forbidden in [".insert(", ".update(", ".delete("] { + assert!( + !preflight.contains(forbidden), + "preflight must remain read-only: {forbidden}" + ); + } + + let persist_start = source + .find("fn persist_editor_pixel_art_result(\n") + .expect("atomic persist implementation"); + let persist_end = source[persist_start..] + .find("fn validate_editor_pixel_art_result_input(") + .map(|offset| persist_start + offset) + .expect("atomic persist validation boundary"); + let persist = &source[persist_start..persist_end]; + for required in [ + "validate_editor_pixel_art_result_input", + "load_editor_pixel_art_existing_records", + "plan_editor_pixel_art_canvas_completion", + "validate_editor_pixel_art_planned_canvas_layout", + "persist_editor_project_layout_v2", + ] { + assert!( + persist.contains(required), + "final transaction must revalidate {required}" + ); + } + } + + #[test] + fn pixel_art_canvas_completion_applies_once_and_replays_without_mutation() { + let layout = json!([ + { + "layerId": "layer-source", + "resourceId": "source-resource", + "zIndex": 4 + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-1", + "resourceId": "generation-dialog:dialog-1", + "dialog": { + "id": "dialog-1", + "mode": "quick-edit", + "status": "generating", + "composerOpen": false, + "errorMessage": "旧错误", + "placeholder": { + "x": 100.0, + "y": 50.0, + "width": 200.0, + "height": 100.0, + "originalWidth": 200.0, + "originalHeight": 100.0 + } + } + } + ]); + let completion = pixel_art_canvas_completion(); + let resource = pixel_art_project_resource(); + let layout_json = layout.to_string(); + let pending_replay_error = + validate_editor_pixel_art_canvas_replay(layout_json.as_str(), &completion, &resource) + .expect_err("stable records must not complete an unresolved dialog during replay"); + assert!(pending_replay_error.contains("尚未由该 operation 收口")); + let applied = + match plan_editor_pixel_art_canvas_layout(layout_json.as_str(), &completion, &resource) + .expect("pixel-art completion should apply") + { + EditorPixelArtCanvasLayoutPlan::Apply(layers_json) => layers_json, + _ => panic!("first completion must apply"), + }; + let applied_value: JsonValue = + serde_json::from_str(applied.as_str()).expect("applied layout"); + let items = applied_value.as_array().expect("layout array"); + let layer = items + .iter() + .find(|item| item["layerId"] == "layer-editor-resource-editor-resource-pixel") + .expect("generated layer"); + assert_eq!(layer["resourceId"], json!("editor-resource-pixel")); + assert_eq!(layer["x"], json!(184.0)); + assert_eq!(layer["y"], json!(92.0)); + assert_eq!(layer["zIndex"], json!(5)); + assert_eq!(layer["generationInputs"]["source"], json!("canvas")); + let dialog = items + .iter() + .find_map(canvas_generation_dialog) + .expect("generation dialog"); + assert_eq!(dialog["status"], json!("idle")); + assert_eq!(dialog["composerOpen"], json!(false)); + assert_eq!( + dialog["generatedLayerId"], + json!("layer-editor-resource-editor-resource-pixel") + ); + assert!(dialog.get("errorMessage").is_none()); + assert!( + validate_editor_pixel_art_canvas_replay(applied.as_str(), &completion, &resource) + .expect("applied replay") + ); + assert!(matches!( + plan_editor_pixel_art_canvas_layout(applied.as_str(), &completion, &resource) + .expect("exact replay"), + EditorPixelArtCanvasLayoutPlan::AlreadyApplied + )); + } + + #[test] + fn pixel_art_canvas_completion_keeps_deleted_dialog_absent() { + let layout = json!([{ + "layerId": "layer-source", + "resourceId": "source-resource" + }]) + .to_string(); + assert!( + !validate_editor_pixel_art_canvas_replay( + layout.as_str(), + &pixel_art_canvas_completion(), + &pixel_art_project_resource(), + ) + .expect("missing dialog is a committed asset-only replay outcome") + ); + } + + #[test] + fn pixel_art_late_completion_does_not_overwrite_active_retry() { + let resource = pixel_art_project_resource(); + let completion = pixel_art_canvas_completion(); + let layout = json!([ + { + "layerId": "layer-editor-resource-editor-resource-pixel", + "resourceId": "editor-resource-pixel", + "objectKey": "generated/editor/pixel/result.png", + "assetObjectId": "assetobj_pixel", + "taskId": "pixel-art-snap-dialog-1" + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-1", + "resourceId": "generation-dialog:dialog-1", + "dialog": { + "id": "dialog-1", + "mode": "quick-edit", + "status": "generating", + "generatedLayerId": "layer-editor-resource-editor-resource-pixel", + "placeholder": { + "x": 100.0, + "y": 50.0, + "width": 200.0, + "height": 100.0, + "originalWidth": 200.0, + "originalHeight": 100.0 + } + } + } + ]); + let error = validate_editor_pixel_art_canvas_replay( + layout.to_string().as_str(), + &completion, + &resource, + ) + .expect_err("late completion must not overwrite an active retry"); + assert!(error.contains("在途重试")); + } + #[test] fn spritesheet_slice_batch_validation_accepts_complete_owned_cohort() { let batch = spritesheet_slice_batch(); @@ -9591,6 +10902,102 @@ mod tests { .expect("legacy self-reference should also be ignored when resource truth is empty"); } + // 中文注释:模拟 2026-07-30 之前的历史行——model 列存的是内部处理模型。owner 读边界会把 + // 它连同 provider 一起脱敏,客户端拿不到权威值,只能回写一个按来源链推导出的展示值。 + fn redacted_model_resource_row() -> EditorProjectResource { + let now = Timestamp::from_micros_since_unix_epoch(1_000_000); + EditorProjectResource { + resource_id: "resource-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + asset_object_id: None, + image_src: "/generated/resource-1.png".to_string(), + object_key: Some("generated/resource-1.png".to_string()), + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: Some("BgFilter complex".to_string()), + provider: Some("BgFilter".to_string()), + task_id: None, + source_resource_id: None, + created_at: now, + updated_at: now, + asset_kind: None, + generation_inputs_json: None, + public_showcase_enabled: true, + } + } + + #[test] + fn structured_canvas_layer_defers_redacted_model_and_provider_to_resource_truth() { + // 中文注释:脱敏造成的差异必须以资源行为准,而不是整次保存 400。 + let resource = redacted_model_resource_row(); + let layout = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "sourceType": "generated", + "src": "/generated/resource-1.png", + "model": "gpt-image-2", + "provider": "VectorEngine" + }]); + let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("resource-backed layout should parse"); + + normalize_structured_canvas_layer_against_resource(&mut parsed.layers[0], Some(&resource)) + .expect("被读边界脱敏的字段不得阻断结构化保存"); + + assert!(!parsed.layers[0].item_json.contains("model")); + assert!(!parsed.layers[0].item_json.contains("provider")); + } + + #[test] + fn structured_canvas_layer_read_back_drops_source_type_for_resource_backed_layers() { + // 中文注释:钉住缺陷 2 的服务端根因——校验通过后 source_type 被归还资源行、图层列置空, + // 读回时整个 sourceType 键都不存在。api-server 读边界的回填正是建立在这个前提上;这里 + // 若被改成「None 也写空串」或去掉条件,客户端就会重新拿不到权威值。 + let resource = redacted_model_resource_row(); + let layout = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "sourceType": "generated", + "src": "/generated/resource-1.png" + }]); + let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("resource-backed layout should parse"); + + normalize_structured_canvas_layer_against_resource(&mut parsed.layers[0], Some(&resource)) + .expect("与资源行一致的 sourceType 必须放行"); + assert_eq!(parsed.layers[0].source_type, None); + + let serialized = serialize_structured_canvas_layout(&parsed) + .expect("structured layout should serialize"); + assert!(!serialized.contains("sourceType")); + } + + #[test] + fn structured_canvas_layer_still_rejects_conflicting_source_type() { + // 中文注释:`model` / `provider` 改成以资源行为准,是因为读边界脱敏了它们、判等不可满足。 + // `sourceType` 不同——读边界会回填权威值,所以它仍然是硬约束,不得被顺手一起放宽。 + let resource = redacted_model_resource_row(); + let layout = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "sourceType": "uploaded", + "src": "/generated/resource-1.png" + }]); + let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("resource-backed layout should parse"); + + let error = normalize_structured_canvas_layer_against_resource( + &mut parsed.layers[0], + Some(&resource), + ) + .expect_err("与资源行冲突的 sourceType 必须失败关闭"); + assert!(error.contains("sourceType")); + } + #[test] fn structured_canvas_existing_local_sequence_only_allows_typed_layout_changes() { let layout = self_contained_local_sequence_layout(); @@ -9909,18 +11316,20 @@ mod tests { json!({ "customExtension": true }) ); - let mut conflicting = json!({ "provider": "legacy-provider" }) + // 中文注释:改用 taskId 举例。model / provider 已改为以资源行为准(读边界会脱敏它们, + // 客户端拿不到权威值,判等对正常路径不可满足),仍然判等的是这一类未脱敏字段。 + let mut conflicting = json!({ "taskId": "legacy-task" }) .as_object() .expect("metadata object") .clone(); let error = take_matching_optional_resource_string( &mut conflicting, - "provider", - Some("vector-engine"), + "taskId", + Some("task-1"), "layer-1", ) - .expect_err("conflicting provider must fail closed"); - assert!(error.contains("provider")); + .expect_err("conflicting taskId must fail closed"); + assert!(error.contains("taskId")); let mut generation_inputs = Some(r#"{ "style": "clay", "seed": 7 }"#.to_string()); merge_optional_resource_json_metadata( diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 1145c58a8..79ac270a8 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -40,6 +40,7 @@ const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); +const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< @@ -74,7 +75,10 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({ vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, - uploadEditorMediaAssetObjectFile: vi.fn(), + // 中文注释:inline 源图上传走的是 object-only 版本,不是带签名 URL 的那个。留成裸 vi.fn() + // 会返回 undefined,取 objectKey 抛的 TypeError 被 extractUiDesignAssets 的 catch 吞成 + // window.alert,最终只表现为「提取接口一次都没调」,报错里看不到任何线索。 + uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock, })); vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({ @@ -113,6 +117,17 @@ describe('ImageCanvasEditorView generation integration', () => { objectKey: 'generated-character-drafts/editor/generation-references/marked-ui-design.png', assetObjectId: 'asset-object-marked-ui-design', + legacyPublicPath: + '/generated-character-drafts/editor/generation-references/marked-ui-design.png', + src: 'https://oss.example.com/marked-ui-design.png', + }); + uploadEditorMediaAssetObjectFileMock.mockReset(); + uploadEditorMediaAssetObjectFileMock.mockResolvedValue({ + objectKey: + 'generated-character-drafts/editor/generation-references/marked-ui-design.png', + assetObjectId: 'asset-object-marked-ui-design', + legacyPublicPath: + '/generated-character-drafts/editor/generation-references/marked-ui-design.png', }); }); @@ -3387,7 +3402,11 @@ describe('ImageCanvasEditorView generation integration', () => { 'https://assets.test/generated-character-drafts/editor/frame1.png', ); }); - expect(screen.getByText('1/48')).toBeTruthy(); + // 中文注释:分子不能钉死。序列帧图层默认自动播放(frames.length > 1 即 isPlaying), + // 48 帧 6 秒算出 125ms 的真实 setInterval,而这里用的是真实定时器下的 waitFor—— + // 上一条 waitFor 在 CI 上多轮询一次就已经越过 125ms,计数器变成 2/48。要断言的是 + // 「按序列帧播放器渲染、总帧数 48」(下一行排除视频元素),分母才是这条断言的意义。 + expect(screen.getByText(/^\d+\/48$/u)).toBeTruthy(); expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull(); expect(screen.getByText('动作')).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 1592364e8..77d1f9a79 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -1,17 +1,26 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import type { + EditorProjectLayerSnapshot, + EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; import { CANVAS_WORLD_ORIGIN, canvasDisplayScaleToViewportScale, canvasDisplayViewportToViewport, + collectExpiredInlineGenerationDialogIds, createLayerFromAsset, DEFAULT_CANVAS_BACKGROUND_COLOR, + dropDeadInlineGenerationPlaceholders, formatCanvasDisplayScalePercent, hydrateCanvasGenerationDialog, hydrateLayer, + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS, normalizeAssetLibrary, normalizeCanvasBackgroundHex, + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, resolveLayerResourceAssetKind, + resolveNextInlineGenerationDialogExpiryAt, resolveSnappedLayerPosition, serializeCanvasLayout, serializeLayer, @@ -23,7 +32,53 @@ import type { CanvasGenerationDialogState, CanvasLayer, EditorAsset, + PerfectPixelOperationSnapshot, } from './ImageCanvasEditorTypes'; +import { PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS } from './useImageCanvasGenerationWorkflow'; + +function buildPerfectPixelOperation( + dialogId: string, +): PerfectPixelOperationSnapshot { + return { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'ref:project-resource:resource-source', + projectId: 'project-1', + sourceResourceId: 'resource-source', + assetKind: 'character', + generationInputs: { + fields: [{ title: '提示词', value: '像素角色' }], + references: [ + { + title: '源图', + label: '角色原图', + refType: 'project-resource', + refId: 'resource-source', + }, + ], + }, + assetFolderId: 'project', + assetLabel: '角色 · 完美像素', + canvasCompletion: { + dialogId, + title: '角色 · 完美像素', + placeholder: { + x: 100, + y: 120, + width: 320, + height: 320, + originalWidth: 640, + originalHeight: 640, + }, + }, + }, + submittedAt: 1_700_000_000_000, + reconcileUntil: 1_700_000_075_000, + }; +} describe('ImageCanvasEditorModel', () => { it('keeps the resource default kind separate from a layer override', () => { @@ -314,6 +369,7 @@ describe('ImageCanvasEditorModel', () => { originalHeight: 768, zIndex: 9, sourceType: 'generated', + resourcePersistenceState: 'registered', objectKey: 'generated/object.png', model: 'birefnet', sourceResourceId: 'resource-provider-source', @@ -332,6 +388,10 @@ describe('ImageCanvasEditorModel', () => { expect(snapshot).not.toHaveProperty('assetKind'); expect(snapshot.assetKindOverride).toBeNull(); expect(snapshot).not.toHaveProperty('generationInputs'); + // 有资源行时,服务端读边界会脱敏内部处理模型并省略 provider,客户端拿到的 model 是按来源 + // 链推导出的展示值;回写它会与资源行原值冲突并让整次结构化保存 400。 + expect(snapshot).not.toHaveProperty('model'); + expect(snapshot).not.toHaveProperty('provider'); const hydrated = hydrateLayer( snapshot, @@ -373,6 +433,77 @@ describe('ImageCanvasEditorModel', () => { expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士'); }); + it('keeps model metadata on self-contained local sequences that have no resource row', () => { + // 中文注释:角色动画逐帧层用 local- 资源 id、image-sequence、无 objectKey,服务端 + // normalize 走 resource == None 早退分支,只摘 assetKind 就把 item 原样写回——item_json + // 是这类图层元数据的唯一存储。停发 model 会让它在下一次保存后永久丢失。 + const layer: CanvasLayer = { + id: 'layer-character-animation', + resourceId: 'local-resource-character-animation-1', + title: '角色动作', + src: '/generated/sequence/frame01.png', + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'generated', + mediaType: 'image-sequence', + imageSequenceFrames: [ + { + frameIndex: 1, + imageSrc: '/generated/sequence/frame01.png', + width: 320, + height: 240, + }, + ], + model: 'seedance2.0-fast', + provider: 'ark', + }; + + const snapshot = serializeLayer(layer); + expect(snapshot.model).toBe('seedance2.0-fast'); + expect(snapshot.provider).toBe('ark'); + + const hydrated = hydrateLayer(snapshot, new Map()); + expect(hydrated?.model).toBe('seedance2.0-fast'); + expect(serializeLayer(hydrated!).model).toBe('seedance2.0-fast'); + }); + + it('keeps the resource sourceType across a structured layout round trip', () => { + const layer: CanvasLayer = { + id: 'layer-generated', + resourceId: 'resource-generated', + title: '生成图', + src: '/read/generated.png', + x: 0, + y: 0, + width: 512, + height: 512, + originalWidth: 512, + originalHeight: 512, + zIndex: 1, + sourceType: 'generated', + }; + const resources = new Map([ + [ + 'resource-generated', + { imageSrc: '/read/generated.png', sourceType: 'generated' }, + ], + ]); + + // 结构化保存会把校验通过的 sourceType 归还资源行并把图层列置空,读回的布局项没有这个键。 + const { sourceType: _omitted, ...storedSnapshot } = serializeLayer(layer); + expect(storedSnapshot).not.toHaveProperty('sourceType'); + + const hydrated = hydrateLayer(storedSnapshot, resources); + expect(hydrated?.sourceType).toBe('generated'); + // 再次保存必须仍然是 generated,否则服务端会判成「sourceType 与项目资源不一致」。 + expect(serializeLayer(hydrated!).sourceType).toBe('generated'); + }); + it('distinguishes persisted self-contained sequences from unresolved local resources', () => { const validSequence = { layerId: 'layer-sequence', @@ -856,6 +987,407 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('keeps the operation ledger out of the layout and restores it from the local ledger', () => { + const dialogId = 'dialog-perfect-pixel-round-trip'; + const operation = buildPerfectPixelOperation(dialogId); + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + composerOpen: false, + requiresLiveSession: true, + perfectPixelOperation: operation, + }); + + expect(hydrated).toMatchObject({ + id: dialogId, + status: 'pending-confirmation', + perfectPixelOperationId: dialogId, + perfectPixelOperation: operation, + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }); + const serializedLayout = JSON.stringify(layout); + expect(serializedLayout).toContain('"perfectPixelOperationId"'); + expect(serializedLayout).not.toContain('"perfectPixelOperation"'); + expect(serializedLayout).not.toContain(operation.request.sourceImageSrc); + + const { generationDialogs } = splitCanvasLayoutItems( + layout, + new Map(), + undefined, + new Map([[dialogId, operation]]), + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]?.perfectPixelOperation).toEqual(operation); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); + }); + + it('keeps a settled perfect-pixel placeholder valid without any local ledger', () => { + // 中文注释:服务端完成 completion 后只做字段级改写,perfectPixelOperationId 会永久留在 + // 布局里;而账本在收口那一刻就被清掉了。这个组合是每一次**成功**完美像素的必然形状, + // 绝不能被判成失败。 + const dialogId = 'dialog-perfect-pixel-settled'; + const settledSnapshot = { + id: dialogId, + mode: 'quick-edit' as const, + prompt: '完美像素', + status: 'idle' as const, + composerOpen: false, + generatedLayerId: `layer-${dialogId}`, + perfectPixelOperationId: dialogId, + }; + + const hydrated = hydrateCanvasGenerationDialog(settledSnapshot); + + expect(hydrated).toMatchObject({ + id: dialogId, + status: 'idle', + generatedLayerId: `layer-${dialogId}`, + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + expect(hydrated).not.toHaveProperty('perfectPixelOperationId'); + expect(hydrated?.errorMessage).toBeUndefined(); + + // 中文注释:标记的寿命必须与账本对齐——收口后不再写回布局,否则错误形状会一直堆积。 + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }); + expect(JSON.stringify(layout)).not.toContain('"perfectPixelOperationId"'); + }); + + it('heals a settled placeholder that a previous build wrote back as invalid', () => { + // 中文注释:上一版判据会把成功结果写成 failed + perfectPixelOperationInvalid 并落库。 + // 这类已经被写脏的行必须在下一次 hydrate 时自愈,否则错误状态会自我固化。 + const dialogId = 'dialog-perfect-pixel-poisoned'; + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'failed', + composerOpen: false, + generatedLayerId: `layer-${dialogId}`, + perfectPixelOperationId: dialogId, + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + + expect(hydrated).toMatchObject({ id: dialogId, status: 'idle' }); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + expect(hydrated?.errorMessage).toBeUndefined(); + }); + + it('drops the inline legacy ledger of an already settled placeholder without marking it', () => { + const dialogId = 'dialog-perfect-pixel-settled-legacy'; + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [ + { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'idle', + composerOpen: false, + generatedLayerId: `layer-${dialogId}`, + perfectPixelOperation: buildPerfectPixelOperation(dialogId), + } as CanvasGenerationDialogState, + ], + }); + const serializedLayout = JSON.stringify(layout); + expect(serializedLayout).not.toContain('"perfectPixelOperation"'); + expect(serializedLayout).not.toContain('"perfectPixelOperationId"'); + + const { generationDialogs } = splitCanvasLayoutItems(layout); + + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'idle', + }); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); + }); + + it('settles a perfect-pixel placeholder as deletable failure when the local ledger is absent', () => { + const dialogId = 'dialog-perfect-pixel-other-device'; + const operation = buildPerfectPixelOperation(dialogId); + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + composerOpen: false, + perfectPixelOperation: operation, + }); + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }); + + // 中文注释:换设备 / 清缓存 / 隐私模式——布局里标记还在,本机账本读不到。这是明确 + // 设计:占位收口成可删除的失败态,绝不停在无从收口的处理中态。 + const { generationDialogs } = splitCanvasLayoutItems(layout); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'failed', + perfectPixelOperationInvalid: true, + }); + expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation'); + }); + + it('ignores a local ledger entry whose id does not match the placeholder', () => { + const dialogId = 'dialog-perfect-pixel-ledger-mismatch'; + const operation = buildPerfectPixelOperation('dialog-perfect-pixel-other'); + const { generationDialogs } = splitCanvasLayoutItems( + [ + { + itemType: 'generation-dialog', + layerId: `generation-dialog:${dialogId}`, + resourceId: `generation-dialog:${dialogId}`, + dialog: { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + perfectPixelOperationId: dialogId, + }, + } as unknown as EditorProjectLayerSnapshot, + ], + new Map(), + undefined, + new Map([['dialog-perfect-pixel-other', operation]]), + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'failed', + perfectPixelOperationInvalid: true, + }); + expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation'); + }); + + it('preserves legacy 240-second operation identity while clamping its deadline on round-trip', () => { + vi.useFakeTimers(); + const now = 1_700_000_010_000; + vi.setSystemTime(now); + try { + const dialogId = 'dialog-perfect-pixel-legacy-window'; + const operation = buildPerfectPixelOperation(dialogId); + const legacyOperation = { + ...operation, + reconcileUntil: operation.submittedAt + 240_000, + }; + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + composerOpen: false, + perfectPixelOperation: legacyOperation, + }); + const expectedOperation = { + ...legacyOperation, + reconcileUntil: + operation.submittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + }; + + expect(hydrated).toMatchObject({ + id: dialogId, + status: 'pending-confirmation', + perfectPixelOperation: expectedOperation, + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + + const { generationDialogs } = splitCanvasLayoutItems( + serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }), + new Map(), + undefined, + new Map([[dialogId, expectedOperation]]), + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'pending-confirmation', + perfectPixelOperation: expectedOperation, + }); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); + } finally { + vi.useRealTimers(); + } + }); + + it('fails closed instead of replaying an invalid perfect-pixel operation snapshot', () => { + const dialogId = 'dialog-perfect-pixel-invalid'; + const operation = buildPerfectPixelOperation(dialogId); + const invalidOperations: unknown[] = [ + { ...operation, version: 2 }, + { ...operation, operationId: 'another-dialog' }, + { ...operation, taskId: 'pixel-art-snap-another-dialog' }, + { + ...operation, + request: { ...operation.request, projectId: ' ' }, + }, + { + ...operation, + request: { + ...operation.request, + sourceImageSrc: 'data:image/png;base64,unsafe', + }, + }, + { + ...operation, + request: { + ...operation.request, + sourceImageSrc: + 'https://oss.example.test/source.png?Expires=1700000000&Signature=temporary', + }, + }, + { + ...operation, + request: { + ...operation.request, + canvasCompletion: { + ...operation.request.canvasCompletion, + dialogId: 'another-dialog', + }, + }, + }, + { + ...operation, + reconcileUntil: operation.submittedAt + 240_000 + 1, + }, + { ...operation, unknownFutureField: true }, + ]; + + for (const invalidOperation of invalidOperations) { + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + perfectPixelOperation: invalidOperation, + }); + + expect(hydrated).toMatchObject({ + id: dialogId, + status: 'failed', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperation'); + } + }); + + it('preserves a legacy clock-skewed operation while capping its current observation window', () => { + vi.useFakeTimers(); + const now = 1_700_000_000_000; + vi.setSystemTime(now); + try { + const dialogId = 'dialog-perfect-pixel-clock-skew'; + const operation = buildPerfectPixelOperation(dialogId); + const submittedAt = now + 180_000; + const expectedOperation = { + ...operation, + submittedAt: now, + reconcileUntil: now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + }; + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + perfectPixelOperation: { + ...operation, + submittedAt, + reconcileUntil: submittedAt + 240_000, + }, + }); + + expect(hydrated?.perfectPixelOperation).toEqual(expectedOperation); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + + const { generationDialogs } = splitCanvasLayoutItems( + serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }), + new Map(), + undefined, + new Map([[dialogId, expectedOperation]]), + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]?.perfectPixelOperation).toEqual( + expectedOperation, + ); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); + } finally { + vi.useRealTimers(); + } + }); + + it('round-trips the blocked marker after clearing an invalid operation snapshot', () => { + const dialogId = 'dialog-perfect-pixel-blocked'; + const operation = buildPerfectPixelOperation(dialogId); + const failedClosed = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + perfectPixelOperation: { ...operation, taskId: 'wrong-task' }, + }); + const { generationDialogs } = splitCanvasLayoutItems( + serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [failedClosed as CanvasGenerationDialogState], + }), + ); + + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'failed', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation'); + }); + + it('fails closed when pending-confirmation has no operation journal', () => { + const hydrated = hydrateCanvasGenerationDialog({ + id: 'dialog-perfect-pixel-orphaned-pending', + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + }); + + expect(hydrated).toMatchObject({ + status: 'failed', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperation'); + }); + it('defaults restored supported image styles to none and drops them from other modes', () => { expect( hydrateCanvasGenerationDialog({ @@ -1093,4 +1625,378 @@ describe('ImageCanvasEditorModel', () => { horizontal: 160, }); }); + + it('keeps the legacy placeholder window above source preparation', () => { + // 中文注释:占位一旦挂上 perfectPixelOperationId 就退出 legacy requiresLiveSession 清理, + // 所以这个窗口只需覆盖标记写入之前的那一段——源图解析/直传。POST 与对账由账本自己保护。 + expect(INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS).toBeGreaterThan( + PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS, + ); + expect( + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS - + PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS, + ).toBeGreaterThanOrEqual(90_000); + }); + + describe('inline placeholder expiry helpers', () => { + const dialog = ( + overrides: Partial, + ): CanvasGenerationDialogState => + ({ + id: 'dialog-1', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + ...overrides, + }) as CanvasGenerationDialogState; + + it('reports an inline placeholder as expired only after the window elapses', () => { + // 中文注释:与 dropDeadInlineGenerationPlaceholders 是同一条规则的两个作用面,边界 + // 必须一致——正好到期不算过期,超过一毫秒才算。 + const now = 1_700_000_000_000; + const live = [ + dialog({ + requiresLiveSession: true, + generationStartedAt: now - 240_000, + }), + ]; + const stale = [ + dialog({ + requiresLiveSession: true, + generationStartedAt: now - 240_001, + }), + ]; + + expect(collectExpiredInlineGenerationDialogIds(live, now)).toEqual([]); + expect(collectExpiredInlineGenerationDialogIds(stale, now)).toEqual([ + 'dialog-1', + ]); + }); + + it('never expires queue-backed or settled placeholders', () => { + // 中文注释:队列型占位的 job 在服务端继续跑,误清会让用户以为没发生而重复提交; + // 已终态的占位也不该被自动清掉,那是用户要自己处置的失败卡片。 + const now = 1_700_000_000_000; + const dialogs = [ + dialog({ id: 'queued', generationStartedAt: now - 999_999 }), + dialog({ + id: 'settled', + requiresLiveSession: true, + status: 'failed', + generationStartedAt: now - 999_999, + }), + ]; + + expect(collectExpiredInlineGenerationDialogIds(dialogs, now)).toEqual([]); + expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBeNull(); + }); + + it('never expires a generating placeholder backed by a perfect-pixel operation snapshot', () => { + const operationDialogId = 'operation-backed'; + const operationBackedDialog = dialog({ + id: operationDialogId, + requiresLiveSession: true, + generationStartedAt: 1, + perfectPixelOperation: buildPerfectPixelOperation(operationDialogId), + }); + + expect( + collectExpiredInlineGenerationDialogIds( + [operationBackedDialog], + 1_700_000_000_000, + ), + ).toEqual([]); + expect( + resolveNextInlineGenerationDialogExpiryAt([operationBackedDialog]), + ).toBeNull(); + }); + + it('expires a live-session placeholder that carries no usable timestamp', () => { + // 中文注释:兜底方向与快照侧一致——按保留会让这类占位永久转下去。 + expect( + collectExpiredInlineGenerationDialogIds( + [dialog({ requiresLiveSession: true })], + 1_700_000_000_000, + ), + ).toEqual(['dialog-1']); + }); + + it('resolves the earliest expiry so callers can arm a single timer', () => { + // 中文注释:到期时刻可以精确算出来,调用方据此挂一次性定时器而不是轮询。 + const now = 1_700_000_000_000; + const dialogs = [ + dialog({ + id: 'later', + requiresLiveSession: true, + generationStartedAt: now - 10_000, + }), + dialog({ + id: 'sooner', + requiresLiveSession: true, + generationStartedAt: now - 60_000, + }), + ]; + + expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBe( + now - 60_000 + 240_000, + ); + }); + }); + + describe('dropDeadInlineGenerationPlaceholders', () => { + const buildProject = ( + layers: EditorProjectLayerSnapshot[], + ): EditorProjectSnapshot => ({ + projectId: 'project-1', + title: '画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers, + resources: [], + updatedAt: '2026-08-03T00:00:00.000Z', + }); + + const withCanvasMirror = ( + project: EditorProjectSnapshot, + layers: EditorProjectLayerSnapshot[], + ): EditorProjectSnapshot => ({ + ...project, + canvas: { + canvasId: 'canvas-1', + projectId: project.projectId, + title: project.title, + viewport: project.viewport, + layers, + updatedAt: project.updatedAt, + }, + }); + + const buildDialogItem = ( + id: string, + dialog: Record, + ): EditorProjectLayerSnapshot => + ({ + itemType: 'generation-dialog', + layerId: `generation-dialog:${id}`, + resourceId: `generation-dialog:${id}`, + dialog: { id, mode: 'quick-edit', prompt: '完美像素', ...dialog }, + }) as unknown as EditorProjectLayerSnapshot; + + it('drops generating placeholders whose only owner was a dead session', () => { + const project = buildProject([ + buildDialogItem('dead', { + status: 'generating', + requiresLiveSession: true, + }), + ]); + + const result = dropDeadInlineGenerationPlaceholders(project); + + expect(result.droppedCount).toBe(1); + expect(result.project.layers).toEqual([]); + }); + + it('keeps a live-session placeholder that is still inside the live window', () => { + // 中文注释:这是多标签页的核心场景。B 标签打开同一项目时会 hydrate 到 A 标签正在用的 + // 活占位——原先的结构性判据(「从服务端读回来的必然属于已死会话」)在这里是假的,会被 + // 当成孤儿剥离,再由 B 下一次布局保存以当前 revision 合法写回,把 A 的占位删掉。 + const now = 1_700_000_000_000; + const project = buildProject([ + buildDialogItem('live', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 30_000, + }), + ]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + + expect(result.droppedCount).toBe(0); + expect(result.project).toBe(project); + }); + + it('keeps a live-session placeholder right up to the window boundary', () => { + // 中文注释:窗口是「服务端最坏 90 秒 + 客户端 120 秒上限 + 余量」推出来的,边界必须是 + // 包含式:正好 180 秒时操作仍可能刚刚收口,不能剥。 + const now = 1_700_000_000_000; + const project = buildProject([ + buildDialogItem('boundary', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 240_000, + }), + ]); + + expect( + dropDeadInlineGenerationPlaceholders(project, now).droppedCount, + ).toBe(0); + }); + + it('drops a live-session placeholder once the window has elapsed', () => { + // 中文注释:超过窗口意味着客户端早已 abort 并把占位改成 failed 或移除,服务端也越过了 + // 90 秒硬上限——此时还停在 generating 就确定是孤儿。 + const now = 1_700_000_000_000; + const project = buildProject([ + buildDialogItem('stale', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 240_001, + }), + ]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + + expect(result.droppedCount).toBe(1); + expect(result.project.layers).toEqual([]); + }); + + it('drops a dead legacy placeholder from both layout mirrors exactly once', () => { + const now = 1_700_000_000_000; + const stale = buildDialogItem('stale-mirrored', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 240_001, + }); + const project = withCanvasMirror(buildProject([stale]), [stale]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + + expect(result.droppedCount).toBe(1); + expect(result.project.layers).toEqual([]); + expect(result.project.canvas?.layers).toEqual([]); + }); + + it('drops a live-session placeholder that carries no usable timestamp', () => { + // 中文注释:兜底方向必须是剥离。按「保留」会让这类占位永久留在画布上;按「剥离」最坏 + // 只是退回引入时间窗之前的行为。 + const now = 1_700_000_000_000; + for (const generationStartedAt of [ + undefined, + Number.NaN, + 'not-a-number', + ]) { + const project = buildProject([ + buildDialogItem('no-timestamp', { + status: 'generating', + requiresLiveSession: true, + ...(generationStartedAt === undefined + ? {} + : { generationStartedAt }), + }), + ]); + + expect( + dropDeadInlineGenerationPlaceholders(project, now).droppedCount, + ).toBe(1); + } + }); + + it('keeps generating placeholders backed by a durable job', () => { + // 中文注释:去除背景恒队列、图片生成默认队列,它们的 job 在服务端继续跑,worker 会 + // 替换占位。刷新后必须原样恢复,误清会让用户以为操作没发生而重复提交。 + const project = buildProject([ + buildDialogItem('queued', { status: 'generating' }), + buildDialogItem('queued-explicit-false', { + status: 'generating', + requiresLiveSession: false, + }), + ]); + + const result = dropDeadInlineGenerationPlaceholders(project); + + expect(result.droppedCount).toBe(0); + expect(result.project).toBe(project); + }); + + it('keeps generating and pending placeholders backed by a perfect-pixel operation snapshot', () => { + const now = 1_700_000_000_000; + for (const status of ['generating', 'pending-confirmation'] as const) { + const dialogId = `operation-${status}`; + const operationBacked = buildDialogItem(dialogId, { + status, + requiresLiveSession: true, + generationStartedAt: now - 999_999, + perfectPixelOperation: buildPerfectPixelOperation(dialogId), + }); + const project = withCanvasMirror(buildProject([operationBacked]), [ + operationBacked, + ]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + + expect(result.droppedCount).toBe(0); + expect(result.project).toBe(project); + } + }); + + it('preserves an invalid operation journal long enough for hydration to fail closed', () => { + const now = 1_700_000_000_000; + const invalidJournal = buildDialogItem('invalid-operation', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 999_999, + perfectPixelOperation: { + ...buildPerfectPixelOperation('invalid-operation'), + taskId: 'wrong-task', + }, + }); + const project = buildProject([invalidJournal]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + const { generationDialogs } = splitCanvasLayoutItems( + result.project.layers, + ); + + expect(result.droppedCount).toBe(0); + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: 'invalid-operation', + status: 'failed', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation'); + }); + + it('keeps settled inline placeholders and unrelated layout items', () => { + const settled = buildDialogItem('settled', { + status: 'failed', + requiresLiveSession: true, + }); + const imageLayer = { + itemType: 'layer', + layerId: 'layer-1', + resourceId: 'resource-1', + } as unknown as EditorProjectLayerSnapshot; + const project = buildProject([settled, imageLayer]); + + const result = dropDeadInlineGenerationPlaceholders(project); + + expect(result.droppedCount).toBe(0); + expect(result.project.layers).toEqual([settled, imageLayer]); + }); + + it('round-trips the marker through hydration so reloads can still detect it', () => { + // 中文注释:hydrate 是白名单式的,漏掉这个字段会让标记在一次「加载→保存」后消失, + // 孤儿占位重新变得不可识别。 + const hydrated = hydrateCanvasGenerationDialog({ + id: 'dialog-1', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + requiresLiveSession: true, + }); + + expect(hydrated?.requiresLiveSession).toBe(true); + + const [item] = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }); + + expect( + (item as unknown as { dialog: { requiresLiveSession?: boolean } }) + .dialog.requiresLiveSession, + ).toBe(true); + }); + }); }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 9a93284f2..81018a9a6 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -1,7 +1,10 @@ import type { + EditorAssetGenerationInputs, EditorAssetLibrarySnapshot, EditorCharacterAnimationGenerationResult, + EditorPixelArtSnapInput, EditorProjectLayerSnapshot, + EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; import type { CanvasAssetKind, @@ -15,6 +18,7 @@ import type { CharacterReferenceImage, EditorAsset, EditorAssetFolder, + PerfectPixelOperationSnapshot, SnapCandidate, } from './ImageCanvasEditorTypes'; @@ -277,6 +281,26 @@ function serializeImageSequenceFrames( return serializedFrames.length ? serializedFrames : undefined; } +/** + * 中文注释:`model` / `provider` 只有在图层挂着项目资源行时才交给资源行权威持有。 + * + * 有资源行:服务端读边界会脱敏内部处理模型(`model`)并无条件省略 `provider`,客户端拿到的 + * `layer.model` 是脱敏后按来源链推导出的展示值,回写必然与资源行原值冲突,被结构化保存判为 + * 「与项目资源不一致」而整次 400。保存时服务端本就会剥离这两个字段,也不参与画布布局哈希, + * 因此直接不发。 + * + * 没有资源行(自包含的 legacy 本地图片序列,例如角色动画逐帧层):服务端 + * `normalize_structured_canvas_layer_against_resource` 走的是 `resource == None` 早退分支, + * 只摘掉 `assetKind` 就把 item 原样写回,item_json 是这些元数据的**唯一**存储。此时停发会让 + * 模型信息在下一次保存后永久丢失,图片信息、ZIP 导出与快速编辑默认模型一起静默退化。 + */ +function serializeResourceOwnedModelFields(layer: CanvasLayer) { + if (layer.resourcePersistenceState === 'registered') { + return {}; + } + return { model: layer.model, provider: layer.provider }; +} + export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot { return { layerId: layer.id, @@ -304,8 +328,7 @@ export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot { ), prompt: layer.prompt, actualPrompt: layer.actualPrompt, - model: layer.model, - provider: layer.provider, + ...serializeResourceOwnedModelFields(layer), taskId: layer.taskId, objectKey: layer.objectKey, assetObjectId: layer.assetObjectId, @@ -334,6 +357,291 @@ type CanvasSettingsLayoutSnapshot = EditorProjectLayerSnapshot & { export type CanvasLayoutItems = EditorProjectLayerSnapshot[]; const CANVAS_SETTINGS_LAYOUT_ITEM_ID = 'canvas-settings:default'; +const PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX = 'pixel-art-snap-'; +// 中文注释:从稳定请求快照写入开始,提交与项目事实对账共用 75 秒绝对窗口。截止时间随 +// durable operation 持久化并在 hydrate 后继续沿用;读取侧还会把跨设备时钟偏差限制在 +// “从当前最多再观察一个窗口”。POST 回包与素材刷新都不能替同一次 operation 续期。 +export const PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 75_000; +// 中文注释:第一批曾把 v1 快照写成 240 秒。滚动部署与旧标签页仍可能持久化该形状, +// 所以读取侧保留兼容上限;它只决定快照是否可信,不会延长当前 75 秒对账窗口。 +const LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 240_000; +const INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE = + '完美像素操作快照无效,禁止自动重试。'; + +const PERFECT_PIXEL_OPERATION_KEYS = new Set([ + 'version', + 'kind', + 'operationId', + 'taskId', + 'request', + 'submittedAt', + 'reconcileUntil', +]); +const PERFECT_PIXEL_REQUEST_KEYS = new Set([ + 'sourceImageSrc', + 'projectId', + 'sourceResourceId', + 'assetKind', + 'generationInputs', + 'assetFolderId', + 'assetLabel', + 'canvasCompletion', +]); +const PERFECT_PIXEL_COMPLETION_KEYS = new Set([ + 'dialogId', + 'title', + 'placeholder', +]); +const PERFECT_PIXEL_PLACEHOLDER_KEYS = new Set([ + 'x', + 'y', + 'width', + 'height', + 'originalWidth', + 'originalHeight', +]); +const PERFECT_PIXEL_GENERATION_INPUTS_KEYS = new Set(['fields', 'references']); +const PERFECT_PIXEL_GENERATION_FIELD_KEYS = new Set(['title', 'value']); +const PERFECT_PIXEL_GENERATION_REFERENCE_KEYS = new Set([ + 'title', + 'label', + 'refType', + 'refId', +]); + +function isSnapshotRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function hasOnlySnapshotKeys( + value: Record, + allowedKeys: ReadonlySet, +) { + return Object.keys(value).every((key) => allowedKeys.has(key)); +} + +function isOptionalNullableString(value: unknown) { + return value === undefined || value === null || typeof value === 'string'; +} + +function isStableEditorMediaReference(value: unknown): value is string { + if (typeof value !== 'string' || !value.trim()) { + return false; + } + const normalized = value.trimStart().toLowerCase(); + if (normalized.startsWith('data:') || normalized.startsWith('blob:')) { + return false; + } + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return true; + } + const signedQueryKeys = new Set([ + 'expires', + 'signature', + 'x-amz-signature', + 'x-amz-security-token', + 'x-oss-signature', + 'x-oss-security-token', + ]); + return [...url.searchParams.keys()].every( + (key) => !signedQueryKeys.has(key.toLowerCase()), + ); + } catch { + return true; + } +} + +function hydratePerfectPixelGenerationInputs( + value: unknown, +): EditorAssetGenerationInputs | null { + if ( + !isSnapshotRecord(value) || + !hasOnlySnapshotKeys(value, PERFECT_PIXEL_GENERATION_INPUTS_KEYS) || + !Array.isArray(value.fields) || + !Array.isArray(value.references) + ) { + return null; + } + const fields = value.fields.flatMap((field) => { + if ( + !isSnapshotRecord(field) || + !hasOnlySnapshotKeys(field, PERFECT_PIXEL_GENERATION_FIELD_KEYS) || + typeof field.title !== 'string' || + typeof field.value !== 'string' + ) { + return []; + } + return [{ title: field.title, value: field.value }]; + }); + const references: EditorAssetGenerationInputs['references'] = + value.references.flatMap((reference) => { + if ( + !isSnapshotRecord(reference) || + !hasOnlySnapshotKeys( + reference, + PERFECT_PIXEL_GENERATION_REFERENCE_KEYS, + ) || + typeof reference.title !== 'string' || + typeof reference.label !== 'string' || + (reference.refType !== 'project-resource' && + reference.refType !== 'asset') || + typeof reference.refId !== 'string' + ) { + return []; + } + return [ + { + title: reference.title, + label: reference.label, + refType: reference.refType as 'project-resource' | 'asset', + refId: reference.refId, + }, + ]; + }); + if ( + fields.length !== value.fields.length || + references.length !== value.references.length + ) { + return null; + } + return { fields, references }; +} + +/** + * 中文注释:完美像素没有 durable job,恢复与人工重试只能依赖这份精确请求快照。 + * 因此这里按 v1 白名单重建,并交叉校验 dialog / operation / task / completion 身份; + * 任何未知版本、字段漂移或不稳定媒体引用都失败关闭,绝不能据当前画布状态猜测并重放 POST。 + */ +export function hydratePerfectPixelOperation( + value: unknown, + dialogId: string, +): PerfectPixelOperationSnapshot | null { + const now = Date.now(); + if ( + !isSnapshotRecord(value) || + !hasOnlySnapshotKeys(value, PERFECT_PIXEL_OPERATION_KEYS) || + value.version !== 1 || + value.kind !== 'perfect-pixel' || + value.operationId !== dialogId || + value.taskId !== `${PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX}${dialogId}` || + typeof value.submittedAt !== 'number' || + !Number.isFinite(value.submittedAt) || + value.submittedAt <= 0 || + typeof value.reconcileUntil !== 'number' || + !Number.isFinite(value.reconcileUntil) || + value.reconcileUntil < value.submittedAt || + value.reconcileUntil - value.submittedAt > + LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS || + value.submittedAt > + now + LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS + ) { + return null; + } + const normalizedSubmittedAt = Math.min(value.submittedAt, now); + const request = value.request; + if ( + !isSnapshotRecord(request) || + !hasOnlySnapshotKeys(request, PERFECT_PIXEL_REQUEST_KEYS) || + !isStableEditorMediaReference(request.sourceImageSrc) || + typeof request.projectId !== 'string' || + !request.projectId.trim() || + !isOptionalNullableString(request.sourceResourceId) || + !isOptionalNullableString(request.assetKind) || + !isOptionalNullableString(request.assetFolderId) || + !isOptionalNullableString(request.assetLabel) + ) { + return null; + } + const completion = request.canvasCompletion; + if ( + !isSnapshotRecord(completion) || + !hasOnlySnapshotKeys(completion, PERFECT_PIXEL_COMPLETION_KEYS) || + completion.dialogId !== dialogId || + typeof completion.title !== 'string' + ) { + return null; + } + const placeholder = completion.placeholder; + if ( + !isSnapshotRecord(placeholder) || + !hasOnlySnapshotKeys(placeholder, PERFECT_PIXEL_PLACEHOLDER_KEYS) || + ![...PERFECT_PIXEL_PLACEHOLDER_KEYS].every( + (key) => + typeof placeholder[key] === 'number' && + Number.isFinite(placeholder[key]), + ) + ) { + return null; + } + let generationInputs: EditorAssetGenerationInputs | null | undefined; + if (request.generationInputs === undefined) { + generationInputs = undefined; + } else if (request.generationInputs === null) { + generationInputs = null; + } else { + generationInputs = hydratePerfectPixelGenerationInputs( + request.generationInputs, + ); + } + if ( + request.generationInputs !== undefined && + request.generationInputs !== null && + !generationInputs + ) { + return null; + } + + const hydratedRequest: EditorPixelArtSnapInput = { + sourceImageSrc: request.sourceImageSrc, + projectId: request.projectId, + ...(request.sourceResourceId !== undefined + ? { + sourceResourceId: request.sourceResourceId as string | null, + } + : {}), + ...(request.assetKind !== undefined + ? { assetKind: request.assetKind as string | null } + : {}), + ...(generationInputs !== undefined ? { generationInputs } : {}), + ...(request.assetFolderId !== undefined + ? { assetFolderId: request.assetFolderId as string | null } + : {}), + ...(request.assetLabel !== undefined + ? { assetLabel: request.assetLabel as string | null } + : {}), + canvasCompletion: { + dialogId, + title: completion.title, + placeholder: { + x: placeholder.x as number, + y: placeholder.y as number, + width: placeholder.width as number, + height: placeholder.height as number, + originalWidth: placeholder.originalWidth as number, + originalHeight: placeholder.originalHeight as number, + }, + }, + }; + + return { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `${PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX}${dialogId}`, + request: hydratedRequest, + // 中文注释:把未来时间规范到当前时刻,确保收紧后的快照再次序列化、hydrate 时仍合法; + // 过去时间保持不变,不能借刷新给 operation 续期。兼容读入的旧 240 秒快照同样只保留 + // 当前 75 秒绝对窗口。 + submittedAt: normalizedSubmittedAt, + reconcileUntil: Math.min( + value.reconcileUntil, + normalizedSubmittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + ), + }; +} function isPersistedReferenceResourceId(resourceId: string | null | undefined) { const normalizedResourceId = resourceId?.trim(); @@ -416,11 +724,28 @@ function serializeGenerationReferences( }); } +/** + * 中文注释:布局里只写 `perfectPixelOperationId` 标记,不写请求账本本身。 + * + * 账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容,改由 + * `perfectPixelOperationStore` 存在本机(见那里的长注释)。布局仍需要一个标记,否则 + * 换设备打开时无法把这类占位与队列型占位区分开,只能当成普通占位一直转下去。 + * + * 标记的寿命必须与账本对齐:收口后账本会被清掉,标记也就不该再留在布局里。否则每一次成功 + * 的完美像素都会在布局里留下一个「有标记、没账本」的占位,被 hydrate 判成无效。 + */ function serializeDialogReferences( dialog: CanvasGenerationDialogState, ): CanvasGenerationDialogState { + const { perfectPixelOperation, ...persistedDialog } = dialog; + const perfectPixelOperationId = isSettledPerfectPixelDialogRecord( + dialog as unknown as Record, + ) + ? undefined + : (dialog.perfectPixelOperationId ?? perfectPixelOperation?.operationId); return { - ...dialog, + ...persistedDialog, + ...(perfectPixelOperationId ? { perfectPixelOperationId } : {}), specReference: serializeGenerationReference(dialog.specReference), generationReferences: serializeGenerationReferences( dialog.generationReferences, @@ -505,10 +830,263 @@ function isCanvasSettingsLayoutItem( return item.itemType === 'canvas-settings'; } +/** + * 中文注释:从快照里取出指定 id 的全部生成占位原始记录,取不到返回空数组。 + * + * 用原始 record 而不是 hydrate:调用方要判的是服务端写了什么,hydrate 会给缺失字段补默认值 + * (例如 status 缺失时补 `idle`),把「服务端没写」和「服务端写了 idle」混成一种。 + */ +export function findCanvasGenerationDialogRecords( + project: EditorProjectSnapshot, + dialogId: string | null | undefined, +): Record[] { + const normalizedDialogId = dialogId?.trim(); + if (!normalizedDialogId) { + return []; + } + const matchingDialogs: Record[] = []; + for (const item of project.layers) { + if (item.itemType !== 'generation-dialog') { + continue; + } + const dialog = + (item as { dialog?: unknown }).dialog && + typeof (item as { dialog?: unknown }).dialog === 'object' + ? ((item as { dialog?: unknown }).dialog as Record) + : null; + if (dialog?.id === normalizedDialogId) { + matchingDialogs.push(dialog); + } + } + return matchingDialogs; +} + +/** + * 中文注释:占位是否仍未收口。这是本仓库对「这个生成完成了没有」的既有定义,原先只存在于 + * `useImageCanvasGenerationSubmissionWorkflow` 的队列轮询里,现在提取共用。 + * + * 判据必须是 status / generatedLayerId,**不能**是「占位还在不在」。服务端成功回填时会保留 + * 该 dialog 并就地改写(`editor_project.rs` 的 `apply_editor_canvas_generation_items`:置 + * `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`), + * 该行为另有服务端测试钉住。按「在不在」判会把真成功判成失败。 + */ +export function isUnresolvedCanvasGenerationDialogRecord( + dialog: Record | null, +): boolean { + if (!dialog) { + return false; + } + return ( + dialog.status === 'generating' || + dialog.status === 'pending-confirmation' || + typeof dialog.generatedLayerId !== 'string' || + dialog.generatedLayerId.trim() === '' + ); +} + +/** + * 中文注释:完美像素占位是否已经收口,即结果图层已被服务端回填进画布。 + * + * **本机账本只在占位未收口期间存在**:发 POST 前写入,拿到终态就清除。而布局里的 + * `perfectPixelOperationId` 标记寿命无限——服务端完成时只做字段级改写(置 `status: "idle"`、 + * 写入 `generatedLayerId`),从不摘掉这个标记。两者寿命不对称,所以任何「有标记、没账本 + * ⇒ 无效」的判据都必须先排除收口态,否则每一次**成功**的完美像素都会在下一次 hydrate 时 + * 被判成 `failed + perfectPixelOperationInvalid`,并把这个错误状态写回服务端。 + */ +function isSettledPerfectPixelDialogRecord( + dialog: Record | null, +): boolean { + return Boolean(dialog) && !isUnresolvedCanvasGenerationDialogRecord(dialog); +} + +/** + * 中文注释:从占位创建起算的存活窗口。超过它还停在 `generating` 的 inline 占位,确定是孤儿。 + * + * 上界由两侧共同封死:服务端最坏合法时长是处理预算 30 秒加持久化预算 60 秒(都由 + * `timeout_at` 强制),客户端整个 POST 又被 `snapEditorImageToPixelArt` 的 120 秒超时封顶。 + * 客户端整条链的上界是提交前置预算 90 秒加 POST 120 秒 = 210 秒,之后必已 abort 并把占位改成 + * `failed` 或直接移除,服务端也早已越过自己的 90 秒。取 240 秒 = 210 秒客户端上界 + 30 秒余量 + * (网络往返、标签页被挂起后的时钟漂移)。 + * + * 本会话自己的占位另有归属登记豁免,不依赖这个窗口;窗口只用于跨标签页——B 标签不得清掉 + * A 标签仍在合法执行的占位,所以它必须大于 A 的客户端上界。 + */ +export const INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS = 240_000; + +function inlineGenerationPlaceholderExpiryAt( + dialog: CanvasGenerationDialogState, +): number | null { + if ( + dialog.perfectPixelOperation || + dialog.requiresLiveSession !== true || + dialog.status !== 'generating' + ) { + return null; + } + const startedAt = dialog.generationStartedAt; + // 中文注释:缺时间戳按「立即到期」处理,与 dropDeadInlineGenerationPlaceholders 的兜底 + // 方向一致——按保留会让这类占位永久转下去。 + if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) { + return Number.NEGATIVE_INFINITY; + } + return startedAt + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS; +} + +/** + * 中文注释:内存态里已经越过存活窗口的 inline 占位 id。 + * + * 与 `dropDeadInlineGenerationPlaceholders` 是同一条规则的两个作用面:那个跑在**快照**上、 + * 只在项目加载时执行一次;这个跑在**内存 dialog** 上,供页面打开期间的到期清理使用。 + * 少了后者,加载时因未到期而被保留的孤儿占位就再没有任何东西会重新判定,只能转到用户 + * 下一次加载——那正是引入存活窗口带来的回归。 + */ +export function collectExpiredInlineGenerationDialogIds( + dialogs: readonly CanvasGenerationDialogState[], + now: number = Date.now(), +): string[] { + return dialogs + .filter((dialog) => { + const expiryAt = inlineGenerationPlaceholderExpiryAt(dialog); + return expiryAt !== null && now > expiryAt; + }) + .map((dialog) => dialog.id); +} + +/** + * 中文注释:下一个 inline 占位到期的绝对时刻,没有则返回 null。调用方据此挂一次性定时器, + * 而不是轮询——到期时刻是可以精确算出来的。 + */ +export function resolveNextInlineGenerationDialogExpiryAt( + dialogs: readonly CanvasGenerationDialogState[], +): number | null { + let earliest: number | null = null; + for (const dialog of dialogs) { + const expiryAt = inlineGenerationPlaceholderExpiryAt(dialog); + if (expiryAt === null || !Number.isFinite(expiryAt)) { + continue; + } + if (earliest === null || expiryAt < earliest) { + earliest = expiryAt; + } + } + return earliest; +} + +/** + * 中文注释:剥离「只能由已死会话收口」的 generating 占位。 + * + * 原先的判据是一条结构性不变量——活着的那份始终在内存里、永远不经过 hydrate,所以从服务端 + * 读回来的必然属于已死会话。**这条在多标签页下是假的**:B 标签打开同一项目时,会 hydrate 到 + * A 标签正在用的活占位,据此剥离并在自己下一次布局保存里把它写没。CAS 挡不住——B 是以当前 + * revision 写入一份合法布局。(反方向倒是被 CAS 挡住的:A 完成后 B 再写,B 的 revision 已陈旧。) + * + * 旧 inline 占位改为有界时间窗:只有超过 `INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS` + * 才判定为孤儿。带 `perfectPixelOperation` 请求账本的新占位不再走这条 legacy 清理,而由 + * GET-only 对账收口;即便账本损坏也要留给 hydrate 失败关闭,不能在这里静默删卡。 + * + * 时钟取自读取方的 `Date.now()`。同机多标签共享时钟,正是要修的场景,判定精确;跨设备有偏移 + * 风险,但此前是无条件剥离,任何时间窗都不会比原行为更差。 + * + * 只能用在项目首次加载。会话内 applyQueuedEditorGenerationProject 会重新 GET 项目并套用, + * 那时候占位对应的操作正在进行,套用本函数会把自己的活占位清掉。 + */ +function canvasGenerationDialogRecord( + item: EditorProjectLayerSnapshot, +): Record | null { + if (item.itemType !== 'generation-dialog') { + return null; + } + const dialog = (item as { dialog?: unknown }).dialog; + return isSnapshotRecord(dialog) ? dialog : null; +} + +function canvasGenerationDialogMirrorKey(item: EditorProjectLayerSnapshot) { + const dialogId = stringOrNull(canvasGenerationDialogRecord(item)?.id); + return dialogId ? `dialog:${dialogId}` : `layer:${item.layerId}`; +} + +function isDeadLegacyInlineGenerationPlaceholder( + item: EditorProjectLayerSnapshot, + now: number, +) { + const dialog = canvasGenerationDialogRecord(item); + if (dialog?.requiresLiveSession !== true || dialog.status !== 'generating') { + return false; + } + const startedAt = dialog.generationStartedAt; + // 中文注释:缺时间戳按「可剥离」处理。实践中不会出现——`requiresLiveSession` 与 + // `generationStartedAt` 在同一次创建里一起写——但 legacy 兜底仍需避免永久孤儿。 + return ( + typeof startedAt !== 'number' || + !Number.isFinite(startedAt) || + now - startedAt > INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS + ); +} + +export function dropDeadInlineGenerationPlaceholders( + project: EditorProjectSnapshot, + now: number = Date.now(), +): { project: EditorProjectSnapshot; droppedCount: number } { + const mirroredLayers = [...project.layers, ...(project.canvas?.layers ?? [])]; + const operationBackedDialogKeys = new Set( + mirroredLayers.flatMap((item) => { + const dialog = canvasGenerationDialogRecord(item); + // 中文注释:两种形状都算 operation-backed——`perfectPixelOperationId` 是账本移出 + // 布局后的新标记,`perfectPixelOperation` 是内联账本的 legacy 形状。 + return dialog && + (Object.prototype.hasOwnProperty.call( + dialog, + 'perfectPixelOperationId', + ) || + Object.prototype.hasOwnProperty.call(dialog, 'perfectPixelOperation')) + ? [canvasGenerationDialogMirrorKey(item)] + : []; + }), + ); + const droppedDialogKeys = new Set( + mirroredLayers.flatMap((item) => { + const key = canvasGenerationDialogMirrorKey(item); + return !operationBackedDialogKeys.has(key) && + isDeadLegacyInlineGenerationPlaceholder(item, now) + ? [key] + : []; + }), + ); + const droppedCount = droppedDialogKeys.size; + if (droppedCount === 0) { + return { project, droppedCount: 0 }; + } + const dropFromMirror = (layers: EditorProjectLayerSnapshot[]) => + layers.filter( + (item) => + item.itemType !== 'generation-dialog' || + !droppedDialogKeys.has(canvasGenerationDialogMirrorKey(item)), + ); + return { + project: { + ...project, + layers: dropFromMirror(project.layers), + ...(project.canvas + ? { + canvas: { + ...project.canvas, + layers: dropFromMirror(project.canvas.layers), + }, + } + : {}), + }, + droppedCount, + }; +} + export function splitCanvasLayoutItems( items: EditorProjectLayerSnapshot[], resourcesById: Map = new Map(), currentUserId?: string | null, + localPerfectPixelOperations?: ReadonlyMap< + string, + PerfectPixelOperationSnapshot + >, ): { layerItems: EditorProjectLayerSnapshot[]; generationDialogs: CanvasGenerationDialogState[]; @@ -534,6 +1112,7 @@ export function splitCanvasLayoutItems( item.dialog, resourcesById, currentUserId, + localPerfectPixelOperations, ); if (dialog) { generationDialogs.push(dialog); @@ -550,6 +1129,10 @@ export function hydrateCanvasGenerationDialog( value: unknown, resourcesById: Map = new Map(), currentUserId?: string | null, + localPerfectPixelOperations?: ReadonlyMap< + string, + PerfectPixelOperationSnapshot + >, ): CanvasGenerationDialogState | null { if (!value || typeof value !== 'object') { return null; @@ -560,6 +1143,49 @@ export function hydrateCanvasGenerationDialog( if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) { return null; } + // 中文注释:布局内联快照是 legacy 形状——账本改存本机之前写下的占位仍在库里, + // 必须继续认,否则滚动部署会把所有在途操作一次性判死。新写入只有 id 标记。 + const hasLegacyInlinePerfectPixelOperation = + Object.prototype.hasOwnProperty.call(snapshot, 'perfectPixelOperation'); + const legacyInlinePerfectPixelOperation = hasLegacyInlinePerfectPixelOperation + ? hydratePerfectPixelOperation(snapshot.perfectPixelOperation, id) + : null; + const declaredPerfectPixelOperationId = Object.prototype.hasOwnProperty.call( + snapshot, + 'perfectPixelOperationId', + ); + const isPerfectPixelPlaceholder = + hasLegacyInlinePerfectPixelOperation || declaredPerfectPixelOperationId; + const perfectPixelOperation = + legacyInlinePerfectPixelOperation ?? + // 中文注释:只认 id 与占位一致的账本。id 漂移一律当账本不可用失败关闭,绝不按 + // 当前画布状态猜一条请求出来重放。 + (declaredPerfectPixelOperationId && + stringOrNull(snapshot.perfectPixelOperationId) === id + ? (localPerfectPixelOperations?.get(id) ?? null) + : null); + const hasPersistedInvalidPerfectPixelOperationMarker = + Object.prototype.hasOwnProperty.call( + snapshot, + 'perfectPixelOperationInvalid', + ); + // 中文注释:收口态占位不需要账本——服务端已经把结果图层回填进画布,`generatedLayerId` + // 就是证据。账本在收口那一刻已被主动清除,标记却永远留在布局里(服务端不摘),所以下面 + // 这些判据必须先排除收口态,否则每一次成功都会被判成失败,并把错误状态写回服务端。 + // 已经被写脏的历史行也在这里一并纠正:收口态无条件忽略已落库的无效标记。 + const isSettledPerfectPixelPlaceholder = + isPerfectPixelPlaceholder && + isSettledPerfectPixelDialogRecord(snapshot as Record); + // 中文注释:标记在、账本不在且尚未收口,正是「换设备 / 清缓存 / 隐私模式」这条明确设计的 + // 路径。收口为可删除的失败占位即可,不得阻断用户删除或从源图重做。 + const hasInvalidPerfectPixelOperation = + !isSettledPerfectPixelPlaceholder && + ((isPerfectPixelPlaceholder && !perfectPixelOperation) || + hasPersistedInvalidPerfectPixelOperationMarker || + (snapshot.status === 'pending-confirmation' && !perfectPixelOperation)); + const trustedPerfectPixelOperation = hasInvalidPerfectPixelOperation + ? undefined + : perfectPixelOperation; const style = snapshot.mode === 'generate' || snapshot.mode === 'character' || @@ -573,7 +1199,28 @@ export function hydrateCanvasGenerationDialog( id, mode: snapshot.mode, prompt, - status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle', + status: hasInvalidPerfectPixelOperation + ? 'failed' + : // 中文注释:带 generatedLayerId 的完美像素占位按定义已被服务端回填,状态只能是 + // `idle`。这里强制归位,顺带修复被上一版判据写脏成 `failed` 的历史行。 + isSettledPerfectPixelPlaceholder + ? 'idle' + : isGenerationStatus(snapshot.status) + ? snapshot.status + : 'idle', + // 中文注释:只认布尔 true。缺字段的历史占位一律视为未置位,按队列型处理原样恢复, + // 不会被 dropDeadInlineGenerationPlaceholders 误清。 + requiresLiveSession: + snapshot.requiresLiveSession === true ? true : undefined, + ...(isPerfectPixelPlaceholder && !isSettledPerfectPixelPlaceholder + ? { perfectPixelOperationId: id } + : {}), + ...(trustedPerfectPixelOperation + ? { perfectPixelOperation: trustedPerfectPixelOperation } + : {}), + ...(hasInvalidPerfectPixelOperation + ? { perfectPixelOperationInvalid: true } + : {}), composerOpen: typeof snapshot.composerOpen === 'boolean' ? snapshot.composerOpen : true, sourceLayerId: stringOrUndefined(snapshot.sourceLayerId), @@ -694,7 +1341,14 @@ export function hydrateCanvasGenerationDialog( audioDurationSeconds: audioDurationOrNull(snapshot.audioDurationSeconds), aspectRatio: stringOrUndefined(snapshot.aspectRatio), imageSize: stringOrUndefined(snapshot.imageSize), - errorMessage: stringOrUndefined(snapshot.errorMessage), + errorMessage: hasInvalidPerfectPixelOperation + ? INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE + : // 中文注释:服务端回填成功时会清掉 errorMessage,所以收口态占位身上的错误文案一定 + // 是残留——上一版判据写进去的那句「快照无效」正是这样落库的。一并清掉,否则状态 + // 已经纠正回 idle,面板上却还挂着一句失败提示。 + isSettledPerfectPixelPlaceholder + ? undefined + : stringOrUndefined(snapshot.errorMessage), generationStartedAt: numberOrUndefined(snapshot.generationStartedAt), generationFinishedAt: numberOrUndefined(snapshot.generationFinishedAt), placeholder: hydrateGenerationPlaceholder(snapshot.placeholder), @@ -729,6 +1383,15 @@ export function hydrateLayer( : canvasAssetKindOrNull(snapshot.assetKind); const isSelfContainedLocalResource = !resource && isSelfContainedLegacyLocalImageSequence(snapshot); + // 中文注释:结构化保存会把校验通过的 sourceType 归还给资源行并把图层列置空,读回时布局项 + // 里根本没有这个键。此处必须回落资源值——猜 'uploaded' 会让 generated 图层在下一次保存时 + // 被判为「sourceType 与项目资源不一致」,整块画布再也存不上。 + const resourceSourceType = resource?.sourceType; + const hydratedLayerSourceType = isCanvasSourceType(snapshot.sourceType) + ? snapshot.sourceType + : isCanvasSourceType(resourceSourceType) + ? resourceSourceType + : 'uploaded'; return { id: layerId, @@ -757,9 +1420,7 @@ export function hydrateLayer( }; })(), zIndex: numberFromSnapshot(snapshot.zIndex, 1), - sourceType: isCanvasSourceType(snapshot.sourceType) - ? snapshot.sourceType - : 'uploaded', + sourceType: hydratedLayerSourceType, mediaType: resolveHydratedLayerMediaType(snapshot, imageSequenceFrames), thumbnailSrc: stringOrNull(snapshot.thumbnailSrc), imageSequenceFrames: imageSequenceFrames.length @@ -1388,7 +2049,12 @@ function isCanvasGenerationDialogMode( function isGenerationStatus( value: unknown, ): value is CanvasGenerationDialogState['status'] { - return value === 'idle' || value === 'generating' || value === 'failed'; + return ( + value === 'idle' || + value === 'generating' || + value === 'pending-confirmation' || + value === 'failed' + ); } function isSpecGenerationType( diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 8e65ec9af..a83bc9c28 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -216,6 +216,7 @@ function createStageProps(): ImageCanvasStageViewProps { onOpenRedrawPanel: vi.fn(), onOpenCropExpandPanel: vi.fn(), onRemoveBackground: vi.fn(), + onPerfectPixel: vi.fn(), onSplitIconSpritesheet: vi.fn(), onExtractUiDesignAssets: vi.fn(), onUiAssetExtractionToolChange: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index dd5a0ecb9..51294c71a 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -6,6 +6,7 @@ import type { EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, EditorImageGenerationStyle, + EditorPixelArtSnapInput, EditorVideoAspectRatio, EditorVideoModel, EditorVideoResolution, @@ -186,6 +187,16 @@ export type PublicationMaterialsWorkflowId = | 'publication-detail-gallery' | 'publication-promo-poster'; +export type PerfectPixelOperationSnapshot = { + version: 1; + kind: 'perfect-pixel'; + operationId: string; + taskId: string; + request: EditorPixelArtSnapInput; + submittedAt: number; + reconcileUntil: number; +}; + export type GenerateDialogState = { id?: string; mode: @@ -203,7 +214,7 @@ export type GenerateDialogState = { | 'audio-background-music'; prompt: string; assetLabel?: string; - status: 'idle' | 'generating' | 'failed'; + status: 'idle' | 'generating' | 'pending-confirmation' | 'failed'; composerOpen?: boolean; sourceLayerId?: string; generatedLayerId?: string; @@ -241,6 +252,17 @@ export type GenerateDialogState = { aspectRatio?: string; imageSize?: string; errorMessage?: string; + // 中文注释:标记该占位的收口只能由创建它的页面会话完成——链路是同步 HTTP、服务端没有 + // durable job,进程一死就再没有任何东西会把它推向终态。队列型占位(去除背景恒队列、 + // 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后 + // 必须原样恢复。 + requiresLiveSession?: boolean; + // 中文注释:请求账本只存在于本机(见 perfectPixelOperationStore),布局里只留这个 id + // 标记「该占位是一次完美像素操作」。换设备打开时标记还在、账本读不到,占位收口为可删除 + // 的失败态——这是明确设计,不得据此阻断用户删除或重做。 + perfectPixelOperationId?: string; + perfectPixelOperation?: PerfectPixelOperationSnapshot; + perfectPixelOperationInvalid?: boolean; generationStartedAt?: number; generationFinishedAt?: number; placeholder?: { @@ -293,6 +315,7 @@ export type CanvasHistoryActionType = | 'generate-image' | 'expand-image' | 'remove-background' + | 'perfect-pixel' | 'split-atlas' | 'replace-image' | 'show-image' diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index f2cfed4f3..50d05ee39 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -78,7 +78,10 @@ import { getSelectedLayerIds, } from './ImageCanvasSelectionModel'; import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView'; -import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; +import { + requiresGenerationDeleteConfirmation, + useCanvasGenerationDialogs, +} from './useCanvasGenerationDialogs'; import { useCanvasHistory } from './useCanvasHistory'; import { useImageCanvasAssetCanvasBridge, @@ -99,6 +102,7 @@ import { DEFAULT_IMAGE_CANVAS_VIEWPORT, useImageCanvasViewportControls, } from './useImageCanvasViewportControls'; +import { useInlineGenerationPlaceholderExpiry } from './useInlineGenerationPlaceholderExpiry'; const TASK_FOCUS_HORIZONTAL_INSET = 28; const TASK_FOCUS_TOP_INSET = 82; @@ -297,6 +301,13 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer { }; } +// 中文注释:服务端持久化顺序是 OSS PUT → asset object → project resource → editor asset +// → 画布收口,非事务。看到孤儿 generating 占位只能说明最后一步没做完,前面几步可能已经 +// 成功。所以不能断言「什么都没发生」,只能指路让用户自己核对素材库。 +// 加载期剥离与页面打开期的到期清理共用这一条,两条路径对用户完全一致。 +const DEAD_INLINE_PLACEHOLDER_NOTICE = + '上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。'; + export function ImageCanvasEditorView({ onProjectAccessLost, }: ImageCanvasEditorViewProps = {}) { @@ -738,6 +749,7 @@ export function ImageCanvasEditorView({ inactiveGenerateDialogsRef, activeCanvasGenerationDialog, canvasGenerationDialogs, + getCanvasGenerationDialogsSnapshot, openCanvasGenerationDialog, updateCanvasGenerationDialogById, removeCanvasGenerationDialogById, @@ -1152,11 +1164,12 @@ export function ImageCanvasEditorView({ layersRef, viewportRef, canvasGenerationDialogsRef, + getCanvasGenerationDialogsSnapshot, canvasBackgroundColorRef, selectedLayerIdRef, selectedLayerIdsRef, }), - [], + [getCanvasGenerationDialogsSnapshot], ); const projectPersistenceSetters = useMemo( () => ({ @@ -1191,6 +1204,7 @@ export function ImageCanvasEditorView({ appendCanvasLayersWithResources, applyProjectSnapshot, flushProjectPersistence, + deadInlinePlaceholderDropCount, } = useImageCanvasProjectPersistence({ refs: projectPersistenceRefs, setters: projectPersistenceSetters, @@ -1223,7 +1237,9 @@ export function ImageCanvasEditorView({ ) => { captureCanvasHistory(action); applyProjectSnapshot(project); + if (action.type !== 'perfect-pixel') { void refreshAssetLibrary(); + } }, [applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary], ); @@ -1376,6 +1392,7 @@ export function ImageCanvasEditorView({ activeCanvasGenerationDialog, canvasGenerationDialogs, openCanvasGenerationDialog, + activateCanvasGenerationDialog, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, archiveActiveCanvasGenerationDialog, @@ -1397,12 +1414,24 @@ export function ImageCanvasEditorView({ assetFolderId: activeUploadFolderId, upsertGeneratedAsset, applyProjectSnapshot: applyGeneratedProjectSnapshot, + applyProjectSnapshotWithoutHistory: applyProjectSnapshot, + flushProjectPersistence, + refreshAssetLibrary, onWalletBalanceMayHaveChanged: refreshEditorWalletState, }); const handleEditorAgentConfirmSent = useCallback(() => { generationSurface.refreshTaskList(); }, [generationSurface]); const showGenerationWarning = generationSurface.showGenerationWarning; + useEffect(() => { + if (deadInlinePlaceholderDropCount === 0) { + return; + } + showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE); + }, [deadInlinePlaceholderDropCount, showGenerationWarning]); + const handleInlinePlaceholdersExpired = useCallback(() => { + showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE); + }, [showGenerationWarning]); const handleExternalGenerationTasksCompleted = useCallback( (tasks: ExternalGenerationTaskRecord[]) => { if (!projectId || tasks.length === 0) { @@ -1502,6 +1531,10 @@ export function ImageCanvasEditorView({ openRedrawPanel, openCropExpandPanel, removeSelectedLayerBackground, + snapSelectedLayerToPerfectPixels, + activeInlineGenerationDialogOwnership, + perfectPixelLayerIds, + pendingPerfectPixelLayerIds, splitSelectedIconSpritesheet, splittingIconSpritesheetLayerIds, extractUiDesignAssets, @@ -1667,6 +1700,37 @@ export function ImageCanvasEditorView({ }, [openLayerGenerationDialog], ); + const removeCanvasGenerationDialog = useCallback( + (dialogId: string) => { + captureCanvasHistory({ type: 'delete-generation-result', count: 1 }); + removeCanvasGenerationDialogById(dialogId); + setSelectedLayerId(null); + setSelectedLayerIds([]); + setImageContextMenu(null); + setContextMenu(null); + setActiveTool('select'); + }, + [ + captureCanvasHistory, + removeCanvasGenerationDialogById, + setActiveTool, + setContextMenu, + setImageContextMenu, + setSelectedLayerId, + setSelectedLayerIds, + ], + ); + const requestRemoveCanvasGenerationDialog = useCallback( + (dialog: CanvasGenerationDialogState) => { + if (requiresGenerationDeleteConfirmation(dialog)) { + activateCanvasGenerationDialog(dialog); + setPendingGenerationDeleteDialog(dialog); + return; + } + removeCanvasGenerationDialog(dialog.id); + }, + [activateCanvasGenerationDialog, removeCanvasGenerationDialog], + ); const contextMenuLayer = contextMenu?.kind === 'layer' ? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null) @@ -1712,6 +1776,7 @@ export function ImageCanvasEditorView({ selectSingleLayer, onDeleteLayerSideEffects: clearDeletedLayerGenerationState, onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById, + onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog, exportLayerImage, onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning, }); @@ -1811,37 +1876,15 @@ export function ImageCanvasEditorView({ (layerId: string | null) => deleteLayerByIdRef.current(layerId), [], ); - const removeCanvasGenerationDialog = useCallback( - (dialogId: string) => { - captureCanvasHistory({ type: 'delete-generation-result', count: 1 }); - removeCanvasGenerationDialogById(dialogId); - setSelectedLayerId(null); - setSelectedLayerIds([]); - setImageContextMenu(null); - setContextMenu(null); - setActiveTool('select'); - }, - [ - captureCanvasHistory, - removeCanvasGenerationDialogById, - setActiveTool, - setContextMenu, - setImageContextMenu, - setSelectedLayerId, - setSelectedLayerIds, - ], - ); - const requestRemoveCanvasGenerationDialog = useCallback( - (dialog: CanvasGenerationDialogState) => { - if (dialog.status === 'generating') { - activateCanvasGenerationDialog(dialog); - setPendingGenerationDeleteDialog(dialog); - return; - } - removeCanvasGenerationDialog(dialog.id); - }, - [activateCanvasGenerationDialog, removeCanvasGenerationDialog], - ); + // 中文注释:加载期的剥离只跑一次,当时未到期而被保留的孤儿占位需要这里补上到期清理, + // 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。 + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs, + activeInlineGenerationDialogOwnership, + removeCanvasGenerationDialogById, + onPlaceholdersExpired: handleInlinePlaceholdersExpired, + }); + const confirmRemoveGeneratingDialog = useCallback(() => { const dialog = pendingGenerationDeleteDialog; if (!dialog) { @@ -2287,6 +2330,8 @@ export function ImageCanvasEditorView({ quickEditSelectionSourceLayer, generationComposerStyle, selectedToolbarStyle, + perfectPixelLayerIds, + pendingPerfectPixelLayerIds, splittingIconSpritesheetLayerIds, uploadDropTarget, contextMenu, @@ -2340,6 +2385,9 @@ export function ImageCanvasEditorView({ onOpenRedrawPanel: openRedrawPanel, onOpenCropExpandPanel: openCropExpandPanel, onRemoveBackground: removeSelectedLayerBackground, + onPerfectPixel: (layer: CanvasLayer) => { + void snapSelectedLayerToPerfectPixels(layer); + }, onSplitIconSpritesheet: (layer: CanvasLayer) => { void flushProjectPersistence().then(() => splitSelectedIconSpritesheet(layer), diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index 8cd9f5f81..b27b7be9e 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -17,7 +17,6 @@ function mockStateSetter() { return vi.fn() as unknown as Dispatch>; } - function createComposerProps( generateDialog: GenerateDialogState, overrides: Partial< @@ -117,6 +116,118 @@ function renderComposer( } describe('ImageCanvasGenerationComposerView', () => { + it.each([ + { + status: 'pending-confirmation' as const, + message: '结果尚未确认,系统不会自动重复提交。', + }, + { + status: 'failed' as const, + message: '完美像素请求尚未发出,请重试同一操作。', + }, + ])('$status 的完美像素操作只展示原操作重试', ({ status, message }) => { + const dialogId = `dialog-perfect-pixel-${status}`; + const onRetryPerfectPixelOperation = vi.fn(); + const onSubmitImageGeneration = vi.fn(); + + renderComposer( + { + id: dialogId, + mode: 'generate', + prompt: '不应重新提交的普通提示词', + status, + composerOpen: true, + imageModel: 'gpt-image-2', + ...(status === 'failed' ? { errorMessage: message } : {}), + perfectPixelOperation: { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'ref:project-resource:resource-source', + projectId: 'project-1', + sourceResourceId: 'resource-source', + assetKind: 'character', + assetLabel: '角色 · 完美像素', + canvasCompletion: { + dialogId, + title: '角色 · 完美像素', + placeholder: { + x: 100, + y: 120, + width: 320, + height: 320, + originalWidth: 640, + originalHeight: 640, + }, + }, + }, + submittedAt: 1_700_000_000_000, + reconcileUntil: 1_700_000_120_000, + }, + }, + { + onRetryPerfectPixelOperation, + onSubmitImageGeneration, + }, + ); + + const panel = screen.getByRole('dialog', { name: '完美像素操作' }); + expect(within(panel).getByText(message)).toBeTruthy(); + expect(within(panel).queryByRole('textbox')).toBeNull(); + expect(within(panel).queryByText('不应重新提交的普通提示词')).toBeNull(); + expect(within(panel).queryByText('gpt-image-2')).toBeNull(); + expect(within(panel).queryByRole('button', { name: '修改' })).toBeNull(); + + const retryButton = within(panel).getByRole('button', { + name: '重试同一完美像素操作', + }); + expect(within(panel).getAllByRole('button')).toEqual([retryButton]); + + fireEvent.click(retryButton); + + expect(onRetryPerfectPixelOperation).toHaveBeenCalledOnce(); + expect(onRetryPerfectPixelOperation).toHaveBeenCalledWith(dialogId); + expect(onSubmitImageGeneration).not.toHaveBeenCalled(); + }); + + it('无效完美像素标记只读展示错误,不暴露普通生成提交入口', () => { + const onRetryPerfectPixelOperation = vi.fn(); + const onSubmitImageGeneration = vi.fn(); + + renderComposer( + { + id: 'dialog-perfect-pixel-invalid', + mode: 'generate', + prompt: '不得重新生成', + status: 'failed', + composerOpen: true, + imageModel: 'gpt-image-2', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照身份不匹配。', + }, + { + onRetryPerfectPixelOperation, + onSubmitImageGeneration, + }, + ); + + const panel = screen.getByRole('dialog', { name: '完美像素操作' }); + expect(within(panel).getByRole('alert').textContent).toBe( + '完美像素操作快照身份不匹配。', + ); + expect(within(panel).queryByRole('textbox')).toBeNull(); + expect(within(panel).queryByRole('button')).toBeNull(); + expect(within(panel).queryByText('不得重新生成')).toBeNull(); + expect(within(panel).queryByText('gpt-image-2')).toBeNull(); + + fireEvent.submit(panel); + + expect(onRetryPerfectPixelOperation).not.toHaveBeenCalled(); + expect(onSubmitImageGeneration).not.toHaveBeenCalled(); + }); + it('让快速编辑显示提示词、尺寸和模型选择', () => { renderComposer({ mode: 'quick-edit', @@ -140,8 +251,9 @@ describe('ImageCanvasGenerationComposerView', () => { expect(panel.className).not.toContain( 'image-canvas-editor__quick-edit-panel', ); - expect(within(panel).getByRole('textbox', { name: '快速编辑提示词' })) - .toBeTruthy(); + expect( + within(panel).getByRole('textbox', { name: '快速编辑提示词' }), + ).toBeTruthy(); expect( within(panel).queryByRole('button', { name: '添加参考图' }), ).toBeNull(); @@ -163,22 +275,24 @@ describe('ImageCanvasGenerationComposerView', () => { it('让生成UI设计图面板复用普通图片生成面板的纵向结构', () => { const setGenerateDialog = vi.fn(); - renderComposer({ - mode: 'ui-design', - prompt: '', - status: 'idle', - composerOpen: true, - uiDesignSpecReference: null, - imageModel: 'gpt-image-2', - aspectRatio: '16:9', - imageSize: '1K', - }, { - isUiDesignSpecMenuOpen: true, - setGenerateDialog: - setGenerateDialog as unknown as Dispatch< + renderComposer( + { + mode: 'ui-design', + prompt: '', + status: 'idle', + composerOpen: true, + uiDesignSpecReference: null, + imageModel: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '1K', + }, + { + isUiDesignSpecMenuOpen: true, + setGenerateDialog: setGenerateDialog as unknown as Dispatch< SetStateAction >, - }); + }, + ); const panel = screen.getByRole('dialog', { name: '生成UI设计图' }); expect( @@ -201,9 +315,12 @@ describe('ImageCanvasGenerationComposerView', () => { expect( panel.querySelector('.image-canvas-editor__generation-composer-footer'), ).toBeTruthy(); - fireEvent.change(within(panel).getByRole('textbox', { name: 'UI设计要求' }), { - target: { value: '主界面和结算弹窗' }, - }); + fireEvent.change( + within(panel).getByRole('textbox', { name: 'UI设计要求' }), + { + target: { value: '主界面和结算弹窗' }, + }, + ); expect(setGenerateDialog).toHaveBeenCalled(); const menu = screen.getByRole('menu', { name: '参考图来源' }); expect( @@ -279,47 +396,56 @@ describe('ImageCanvasGenerationComposerView', () => { ['publication-cover-image', '游戏首图', '720 x 540'], ['publication-detail-gallery', '详情五图', '720 x 1280'], ['publication-promo-poster', '运营海报', '1280 x 720'], - ] as const)('让%s 宣发素材面板对齐生成角色面板结构', (workflowId, label, sizeLabel) => { - renderComposer({ - mode: 'publication', - prompt: '', - status: 'idle', - composerOpen: true, - publicationWorkflowId: workflowId, - publicationGameInfo: { - gameName: '', - gameCategories: '', - gameDescription: '', - }, - publicationReferences: [], - imageModel: 'gpt-image-2', - aspectRatio: '16:9', - imageSize: '1K', - }); + ] as const)( + '让%s 宣发素材面板对齐生成角色面板结构', + (workflowId, label, sizeLabel) => { + renderComposer({ + mode: 'publication', + prompt: '', + status: 'idle', + composerOpen: true, + publicationWorkflowId: workflowId, + publicationGameInfo: { + gameName: '', + gameCategories: '', + gameDescription: '', + }, + publicationReferences: [], + imageModel: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '1K', + }); - const panel = screen.getByRole('dialog', { name: `${label}生成卡片` }); - expect(panel.className).toContain('image-canvas-editor__character-composer'); - expect(panel.className).toContain('image-canvas-editor__publication-composer'); - expect( - panel.firstElementChild?.className.includes( - 'image-canvas-editor__reference-strip', - ), - ).toBe(true); - expect(within(panel).getByRole('button', { name: '添加参考图' })).toBeTruthy(); - expect( - within(panel).getByRole('textbox', { name: `${label}游戏名` }), - ).toBeTruthy(); - expect( - within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }), - ).toBeTruthy(); - expect(panel.textContent).toContain(sizeLabel); - expect( - panel.querySelector('.image-canvas-editor__generation-composer-footer'), - ).toBeTruthy(); - expect(within(panel).getByRole('button', { name: '生成' }).textContent).toBe( - '生成3泥点', - ); - }); + const panel = screen.getByRole('dialog', { name: `${label}生成卡片` }); + expect(panel.className).toContain( + 'image-canvas-editor__character-composer', + ); + expect(panel.className).toContain( + 'image-canvas-editor__publication-composer', + ); + expect( + panel.firstElementChild?.className.includes( + 'image-canvas-editor__reference-strip', + ), + ).toBe(true); + expect( + within(panel).getByRole('button', { name: '添加参考图' }), + ).toBeTruthy(); + expect( + within(panel).getByRole('textbox', { name: `${label}游戏名` }), + ).toBeTruthy(); + expect( + within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }), + ).toBeTruthy(); + expect(panel.textContent).toContain(sizeLabel); + expect( + panel.querySelector('.image-canvas-editor__generation-composer-footer'), + ).toBeTruthy(); + expect( + within(panel).getByRole('button', { name: '生成' }).textContent, + ).toBe('生成3泥点'); + }, + ); it('恢复宣发素材生成卡片的字段和参考图', () => { renderComposer({ @@ -347,19 +473,25 @@ describe('ImageCanvasGenerationComposerView', () => { const panel = screen.getByRole('dialog', { name: '运营海报生成卡片' }); expect( - (within(panel).getByRole('textbox', { - name: '运营海报游戏名', - }) as HTMLInputElement).value, + ( + within(panel).getByRole('textbox', { + name: '运营海报游戏名', + }) as HTMLInputElement + ).value, ).toBe('马戏团午夜惊魂'); expect( - (within(panel).getByRole('textbox', { - name: '运营海报游戏分类', - }) as HTMLInputElement).value, + ( + within(panel).getByRole('textbox', { + name: '运营海报游戏分类', + }) as HTMLInputElement + ).value, ).toBe('非对称对抗'); expect( - (within(panel).getByRole('textbox', { - name: '运营海报一句话描述游戏', - }) as HTMLTextAreaElement).value, + ( + within(panel).getByRole('textbox', { + name: '运营海报一句话描述游戏', + }) as HTMLTextAreaElement + ).value, ).toBe('找到钥匙,开门逃离马戏团'); expect(within(panel).getByLabelText('首图参考')).toBeTruthy(); expect( @@ -510,10 +642,14 @@ describe('ImageCanvasGenerationComposerView', () => { <> >} + setGenerateDialog={ + setDialog as Dispatch> + } /> {dialog.videoModel} - {dialog.videoDurationSeconds} + + {dialog.videoDurationSeconds} + {dialog.videoResolution} {dialog.placeholder @@ -545,9 +681,9 @@ describe('ImageCanvasGenerationComposerView', () => { expect( within(panel).getByRole('button', { name: '模型 Seedance 2.0 Fast' }), ).toBeTruthy(); - expect(within(panel).getByRole('button', { name: '生成视频' }).textContent).toBe( - '生成40泥点', - ); + expect( + within(panel).getByRole('button', { name: '生成视频' }).textContent, + ).toBe('生成40泥点'); fireEvent.click( within(panel).getByRole('button', { @@ -579,15 +715,18 @@ describe('ImageCanvasGenerationComposerView', () => { .querySelector('.image-canvas-editor__ratio-wireframe') ?.getAttribute('data-ratio'), ).toBe('21:9'); - fireEvent.change(within(paramsPanel).getByRole('slider', { name: '视频时长' }), { - target: { value: '5' }, - }); - fireEvent.click(within(paramsPanel).getByRole('button', { name: '清晰度 720p' })); + fireEvent.change( + within(paramsPanel).getByRole('slider', { name: '视频时长' }), + { + target: { value: '5' }, + }, + ); + fireEvent.click( + within(paramsPanel).getByRole('button', { name: '清晰度 720p' }), + ); fireEvent.click(within(paramsPanel).getByRole('button', { name: '静音' })); - expect( - screen.getByRole('menu', { name: '视频参数选项' }), - ).toBeTruthy(); + expect(screen.getByRole('menu', { name: '视频参数选项' })).toBeTruthy(); expect(screen.getByLabelText('当前视频时长').textContent).toBe('5'); expect(screen.getByLabelText('当前视频清晰度').textContent).toBe('720p'); expect(screen.getByLabelText('当前视频占位').textContent).toBe( @@ -605,10 +744,16 @@ describe('ImageCanvasGenerationComposerView', () => { '生成100泥点', ); - fireEvent.click(screen.getByRole('button', { name: '模型 Seedance 2.0 Fast' })); + fireEvent.click( + screen.getByRole('button', { name: '模型 Seedance 2.0 Fast' }), + ); const modelPanel = screen.getByRole('menu', { name: '视频模型选项' }); - expect(within(modelPanel).queryByRole('button', { name: /Veo/i })).toBeNull(); - fireEvent.click(within(modelPanel).getByRole('button', { name: 'Kling 3.0' })); + expect( + within(modelPanel).queryByRole('button', { name: /Veo/i }), + ).toBeNull(); + fireEvent.click( + within(modelPanel).getByRole('button', { name: 'Kling 3.0' }), + ); expect(screen.getByRole('menu', { name: '视频模型选项' })).toBeTruthy(); expect(screen.getByLabelText('当前视频模型').textContent).toBe('kling3.0'); @@ -677,9 +822,9 @@ describe('ImageCanvasGenerationComposerView', () => { expect(within(panel).queryByText('单次')).toBeNull(); expect(within(panel).queryByText('循环')).toBeNull(); expect(within(panel).queryByText(/BPM/u)).toBeNull(); - expect(within(panel).getByRole('button', { name: '生成游戏音效' }).textContent).toBe( - '生成5泥点', - ); + expect( + within(panel).getByRole('button', { name: '生成游戏音效' }).textContent, + ).toBe('生成5泥点'); fireEvent.click( within(panel).getByRole('button', { name: '音效时长 5秒' }), @@ -699,9 +844,7 @@ describe('ImageCanvasGenerationComposerView', () => { fireEvent.change(durationSlider, { target: { value: '8' } }); expect(screen.getByLabelText('当前音效时长').textContent).toBe('8'); - expect( - screen.getByRole('button', { name: '音效时长 8秒' }), - ).toBeTruthy(); + expect(screen.getByRole('button', { name: '音效时长 8秒' })).toBeTruthy(); fireEvent.submit(panel); expect(onSubmitImageGeneration).toHaveBeenCalledWith( @@ -742,9 +885,10 @@ describe('ImageCanvasGenerationComposerView', () => { ); expect(within(panel).getByText('Suno')).toBeTruthy(); expect(within(panel).queryByText('make_instrumental')).toBeNull(); - expect(within(panel).getByRole('button', { name: '生成游戏背景音乐' }).textContent).toBe( - '生成12泥点', - ); + expect( + within(panel).getByRole('button', { name: '生成游戏背景音乐' }) + .textContent, + ).toBe('生成12泥点'); }); it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => { const onRequestUpload = vi.fn(); @@ -779,7 +923,11 @@ describe('ImageCanvasGenerationComposerView', () => { expect(onRequestUpload).not.toHaveBeenCalled(); expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled(); const menu = screen.getByRole('menu', { name: '参考图来源' }); - expect(within(menu).getByRole('menuitem', { name: '从画布中选择' })).toBeTruthy(); - expect(within(menu).getByRole('menuitem', { name: '上传图片' })).toBeTruthy(); + expect( + within(menu).getByRole('menuitem', { name: '从画布中选择' }), + ).toBeTruthy(); + expect( + within(menu).getByRole('menuitem', { name: '上传图片' }), + ).toBeTruthy(); }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index a5ee38848..3567b1c73 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -108,9 +108,7 @@ type ImageCanvasGenerationComposerViewProps = { setIsUiDesignSpecMenuOpen: Dispatch>; setIsPickingGenerationReferenceFromCanvas: Dispatch>; setIsPickingQuickEditReferenceFromCanvas: Dispatch>; - setIsPickingPublicationReferenceFromCanvas: Dispatch< - SetStateAction - >; + setIsPickingPublicationReferenceFromCanvas: Dispatch>; setIsPickingCharacterSpecFromCanvas: Dispatch>; setIsPickingCharacterReferenceFromCanvas: Dispatch>; setIsPickingIconSpecFromCanvas: Dispatch>; @@ -122,6 +120,7 @@ type ImageCanvasGenerationComposerViewProps = { onSubmitQuickEdit: () => void; onSubmitCropExpand: () => void; onSubmitCharacterAnimation: () => void; + onRetryPerfectPixelOperation?: (dialogId: string) => void; onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void; onUpdateIconDescriptionText: (value: string) => void; onUpdateCharacterAnimationDuration: (frameCountValue: string) => void; @@ -130,6 +129,66 @@ type ImageCanvasGenerationComposerViewProps = { onSpecMenuPointerLeave?: () => void; }; +function PerfectPixelOperationPanel({ + dialog, + style, + onRetry, +}: { + dialog: GenerateDialogState; + style: CSSProperties; + onRetry?: (dialogId: string) => void; +}) { + const isPending = dialog.status === 'pending-confirmation'; + const isPreparedFailure = + dialog.status === 'failed' && Boolean(dialog.perfectPixelOperation); + const isGenerating = dialog.status === 'generating'; + const isInvalid = dialog.perfectPixelOperationInvalid === true; + const message = isInvalid + ? (dialog.errorMessage ?? '完美像素操作快照无效,禁止自动重试。') + : isPending + ? (dialog.errorMessage ?? '结果尚未确认,系统不会自动重复提交。') + : isGenerating + ? '完美像素处理中' + : dialog.status === 'failed' + ? (dialog.errorMessage ?? '完美像素处理失败') + : '完美像素处理已完成'; + return ( +
event.stopPropagation()} + > + 完美像素 + + {message} + + {!isInvalid && + (isPending || isPreparedFailure) && + dialog.id && + dialog.perfectPixelOperation ? ( + onRetry?.(dialog.id!)} + > + 重试同一完美像素操作 + + ) : null} +
+ ); +} + function buildPortalMenuStyle( anchor: HTMLElement | null, placement: 'above' | 'below', @@ -334,7 +393,8 @@ function ImageCanvasVideoGenerationComposerView({ updateVideoDialog({ videoModel: model, videoResolution: - model === VIDEO_MODEL_SEEDANCE_2_FAST && dialog.videoResolution === '1080p' + model === VIDEO_MODEL_SEEDANCE_2_FAST && + dialog.videoResolution === '1080p' ? '720p' : dialog.videoResolution, ...(isSeedanceVideoModel(model) ? {} : { generationReferences: [] }), @@ -484,7 +544,8 @@ function ImageCanvasVideoGenerationComposerView({
{EDITOR_VIDEO_RESOLUTION_OPTIONS.map((option) => { const available = - currentModel.value !== VIDEO_MODEL_SEEDANCE_2_FAST || + currentModel.value !== + VIDEO_MODEL_SEEDANCE_2_FAST || option !== '1080p'; return ( updateVideoDialog({ - videoSound: - soundMode === 'off' ? 'on' : 'off', + videoSound: soundMode === 'off' ? 'on' : 'off', }) } > @@ -748,7 +808,9 @@ function ImageCanvasAudioGenerationComposerView({ }; const updateSoundDuration = (durationSeconds: number) => { - updateAudioDialog({ soundDurationSeconds: normalizeSoundDuration(durationSeconds) }); + updateAudioDialog({ + soundDurationSeconds: normalizeSoundDuration(durationSeconds), + }); }; return ( @@ -770,9 +832,7 @@ function ImageCanvasAudioGenerationComposerView({ value={dialog.prompt} disabled={isGenerating} placeholder={ - isSoundEffect - ? '你希望生成什么音效?' - : '你希望生成什么音乐?' + isSoundEffect ? '你希望生成什么音效?' : '你希望生成什么音乐?' } className="image-canvas-editor__generation-prompt" onChange={(event) => updateAudioDialog({ prompt: event.target.value })} @@ -941,6 +1001,7 @@ export function ImageCanvasGenerationComposerView({ onSubmitQuickEdit, onSubmitCropExpand, onSubmitCharacterAnimation, + onRetryPerfectPixelOperation, onUpdateSpecFormValue, onUpdateIconDescriptionText, onUpdateCharacterAnimationDuration, @@ -948,6 +1009,10 @@ export function ImageCanvasGenerationComposerView({ onSpecMenuPointerEnter, onSpecMenuPointerLeave, }: ImageCanvasGenerationComposerViewProps) { + const isPerfectPixelDialog = Boolean( + generateDialog?.perfectPixelOperation || + generateDialog?.perfectPixelOperationInvalid, + ); return ( <> {isSpecMenuOpen @@ -975,7 +1040,19 @@ export function ImageCanvasGenerationComposerView({ ) : null} - {(generateDialog?.mode === 'generate' || + {isPerfectPixelDialog && + generateDialog && + generateDialog.composerOpen !== false && + generationComposerStyle ? ( + + ) : null} + + {!isPerfectPixelDialog && + (generateDialog?.mode === 'generate' || generateDialog?.mode === 'quick-edit') && generateDialog.composerOpen !== false && generationComposerStyle ? ( @@ -1034,9 +1111,7 @@ export function ImageCanvasGenerationComposerView({ publicationReferenceButtonRef={publicationReferenceButtonRef} isPublicationReferenceMenuOpen={isPublicationReferenceMenuOpen} setGenerateDialog={setGenerateDialog} - setIsPublicationReferenceMenuOpen={ - setIsPublicationReferenceMenuOpen - } + setIsPublicationReferenceMenuOpen={setIsPublicationReferenceMenuOpen} setIsPickingPublicationReferenceFromCanvas={ setIsPickingPublicationReferenceFromCanvas } @@ -1208,9 +1283,7 @@ export function ImageCanvasGenerationComposerView({ /> ) : null} - {cropExpandPanel && - cropExpandSourceLayer && - cropExpandPanelStyle ? ( + {cropExpandPanel && cropExpandSourceLayer && cropExpandPanelStyle ? ( { expect(isProtectedCanvasHistoryAction({ type: 'replace-image' })).toBe( true, ); + expect(formatCanvasHistoryAction({ type: 'perfect-pixel' })).toBe( + '完美像素', + ); + expect(isProtectedCanvasHistoryAction({ type: 'perfect-pixel' })).toBe( + true, + ); expect(isProtectedCanvasHistoryAction({ type: 'move-image' })).toBe(false); }); }); diff --git a/src/components/image-editor/ImageCanvasHistoryModel.ts b/src/components/image-editor/ImageCanvasHistoryModel.ts index adce85b25..b5f8e7e31 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.ts @@ -22,6 +22,7 @@ const CANVAS_HISTORY_ACTION_LABELS: Record< 'generate-image': '生成图片', 'expand-image': '扩展图片', 'remove-background': '移除背景', + 'perfect-pixel': '完美像素', 'split-atlas': '拆分图集', 'replace-image': '替换图片', 'show-image': '显示图片', @@ -46,6 +47,7 @@ const PROTECTED_CANVAS_HISTORY_ACTION_TYPES = new Set< 'generate-image', 'expand-image', 'remove-background', + 'perfect-pixel', 'split-atlas', 'replace-image', ]); diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx index f59103f95..2b200d0c9 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx @@ -78,7 +78,11 @@ function syncPanelFromDialogUpdate( model: normalizedDialog.imageModel ?? normalizedPanel.model, quickEditReferences: normalizedDialog.generationReferences, assetLabel: normalizedDialog.assetLabel, - status: normalizedDialog.status, + // 中文注释:dialog 由 createQuickEditDialog 从面板自身派生,组合器只会做 + // failed→idle 的重置,不会写入 'pending-confirmation' 这类完美像素专属状态。 + // 因此归一化后的 dialog 状态恒等于归一化后的面板状态,直接取面板侧即可, + // 也让面板的三态联合类型不必在这里做运行时收窄。 + status: normalizedPanel.status, errorMessage: normalizedDialog.errorMessage, }; } diff --git a/src/components/image-editor/ImageCanvasRasterEditModel.ts b/src/components/image-editor/ImageCanvasRasterEditModel.ts index 6facd9461..c46c29343 100644 --- a/src/components/image-editor/ImageCanvasRasterEditModel.ts +++ b/src/components/image-editor/ImageCanvasRasterEditModel.ts @@ -1,7 +1,10 @@ import { type EditorBackgroundRemovalInput, type EditorBackgroundRemovalResult, + type EditorPixelArtSnapInput, + type EditorPixelArtSnapResult, removeEditorImageBackground, + snapEditorImageToPixelArt, } from '../../services/image-editor/editorProjectClient'; export type CropExpandInsets = { @@ -222,3 +225,9 @@ export async function removeImageBackground( typeof input === 'string' ? { sourceImageSrc: input } : input, ); } + +export async function snapImageToPerfectPixels( + input: EditorPixelArtSnapInput, +): Promise { + return snapEditorImageToPixelArt(input); +} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index b06f0013e..bdced3445 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -42,6 +42,9 @@ function renderSelectedToolbar( onOpenRedrawPanel: vi.fn(), onOpenCropExpandPanel: vi.fn(), onRemoveBackground: vi.fn(), + onPerfectPixel: vi.fn(), + isPerfectPixelProcessing: false, + isPerfectPixelPendingConfirmation: false, isSplittingIconSpritesheet: false, isPersistingAssetKind: false, onSplitIconSpritesheet: vi.fn(), @@ -66,6 +69,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { '快速编辑', '裁扩按钮', '去除背景按钮', + '完美像素', '改造', '下载按钮', ]); @@ -75,6 +79,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { fireEvent.click( within(toolbar).getByRole('button', { name: '去除背景按钮' }), ); + fireEvent.click(within(toolbar).getByRole('button', { name: '完美像素' })); fireEvent.click(within(toolbar).getByRole('button', { name: '改造' })); fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' })); @@ -85,6 +90,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { props.selectedLayer, ); expect(props.onRemoveBackground).toHaveBeenCalledWith(props.selectedLayer); + expect(props.onPerfectPixel).toHaveBeenCalledWith(props.selectedLayer); expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(props.selectedLayer); expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer); expect( @@ -104,6 +110,19 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { .getByRole('button', { name: '去除背景按钮' }) .querySelector('.lucide-image-off'), ).toBeTruthy(); + expect( + within(toolbar) + .getByRole('button', { name: '完美像素' }) + .querySelector('.lucide-grid-2x2'), + ).toBeTruthy(); + expect( + within(toolbar).getByRole('button', { name: '完美像素' }).textContent, + ).toContain('完美像素'); + expect( + within(toolbar) + .getByRole('button', { name: '完美像素' }) + .getAttribute('title'), + ).toBe('自动识别并规整像素网格'); }); it('renders UI design asset extraction after remove background', () => { @@ -119,6 +138,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { '快速编辑', '裁扩按钮', '去除背景按钮', + '完美像素', '提取素材', '改造', '下载按钮', @@ -142,6 +162,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { '快速编辑', '裁扩按钮', '去除背景按钮', + '完美像素', '拆分图集', '改造', '下载按钮', @@ -184,6 +205,74 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled(); }); + it('renders a disabled loading state while perfect pixel is processing', () => { + const props = renderSelectedToolbar({ + isPerfectPixelProcessing: true, + }); + const button = screen.getByRole('button', { + name: '完美像素处理中', + }); + + expect(button.getAttribute('aria-busy')).toBe('true'); + expect((button as HTMLButtonElement).disabled).toBe(true); + expect( + button.querySelector('.lucide-loader-circle.animate-spin'), + ).toBeTruthy(); + expect(button.querySelector('.lucide-grid-2x2')).toBeNull(); + expect(button.textContent).toContain('处理中'); + + fireEvent.click(button); + fireEvent.click(button); + + expect(props.onPerfectPixel).not.toHaveBeenCalled(); + }); + + it('renders a distinct disabled state while perfect pixel is pending confirmation', () => { + const props = renderSelectedToolbar({ + isPerfectPixelPendingConfirmation: true, + }); + const button = screen.getByRole('button', { + name: '完美像素结果待确认', + }); + + expect(button.getAttribute('title')).toBe( + '结果待确认,双击原占位继续核对或重试', + ); + expect(button.getAttribute('aria-busy')).toBe('false'); + expect((button as HTMLButtonElement).disabled).toBe(true); + expect(button.textContent).toContain('待确认'); + expect(button.textContent).not.toContain('处理中'); + expect(button.querySelector('.lucide-grid-2x2')).toBeTruthy(); + expect(button.querySelector('.lucide-loader-circle')).toBeNull(); + + fireEvent.click(button); + + expect(props.onPerfectPixel).not.toHaveBeenCalled(); + }); + + it('does not invoke perfect pixel while the selected asset kind is persisting', () => { + // 中文注释:请求同时带 assetKind 与 sourceResourceId,本地类型已改但资源尚未落库时 + // 两者不一致,后端会直接 400 并留下失败占位。名称与拆分图集按钮的「素材类型保存中」 + // 必须区分开——icon-spritesheet 图层上两个按钮会同时进入保存态。 + const layer = createLayer({ assetKind: 'icon-spritesheet' }); + const props = renderSelectedToolbar({ + selectedLayer: layer, + isPersistingAssetKind: true, + }); + const button = screen.getByRole('button', { + name: '完美像素等待素材类型保存', + }); + + expect(button.getAttribute('aria-busy')).toBe('true'); + expect((button as HTMLButtonElement).disabled).toBe(true); + expect(button.querySelector('.lucide-grid-2x2')).toBeNull(); + expect(button.textContent).toContain('保存中'); + + fireEvent.click(button); + + expect(props.onPerfectPixel).not.toHaveBeenCalled(); + }); + it('does not invoke atlas splitting while the selected asset kind is persisting', () => { const layer = createLayer({ assetKind: 'icon-spritesheet' }); const props = renderSelectedToolbar({ @@ -227,6 +316,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect( within(toolbar).queryByRole('button', { name: '去除背景按钮' }), ).toBeNull(); + expect( + within(toolbar).queryByRole('button', { name: '完美像素' }), + ).toBeNull(); expect( within(toolbar).queryByRole('button', { name: '生成动画' }), ).toBeNull(); @@ -255,6 +347,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect( within(videoToolbar).queryByRole('button', { name: '去除背景按钮' }), ).toBeNull(); + expect( + within(videoToolbar).queryByRole('button', { name: '完美像素' }), + ).toBeNull(); expect( within(videoToolbar) .getAllByRole('button') @@ -278,6 +373,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect( within(actionToolbar).queryByRole('button', { name: '去除背景按钮' }), ).toBeNull(); + expect( + within(actionToolbar).queryByRole('button', { name: '完美像素' }), + ).toBeNull(); expect( within(actionToolbar) .getAllByRole('button') @@ -341,6 +439,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenRedrawPanel={vi.fn()} onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} + onPerfectPixel={vi.fn()} + isPerfectPixelProcessing={false} + isPerfectPixelPendingConfirmation={false} isSplittingIconSpritesheet={false} isPersistingAssetKind={false} onSplitIconSpritesheet={vi.fn()} @@ -360,6 +461,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenRedrawPanel={vi.fn()} onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} + onPerfectPixel={vi.fn()} + isPerfectPixelProcessing={false} + isPerfectPixelPendingConfirmation={false} isSplittingIconSpritesheet={false} isPersistingAssetKind={false} onSplitIconSpritesheet={vi.fn()} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index b1a423324..3cb296897 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -1,6 +1,7 @@ import { Crop, Download, + Grid2X2, ImageOff, Loader2, PersonStanding, @@ -22,6 +23,9 @@ type ImageCanvasSelectedLayerToolbarViewProps = { onOpenRedrawPanel: (layer: CanvasLayer) => void; onOpenCropExpandPanel: (layer: CanvasLayer) => void; onRemoveBackground: (layer: CanvasLayer) => void; + onPerfectPixel: (layer: CanvasLayer) => void; + isPerfectPixelProcessing: boolean; + isPerfectPixelPendingConfirmation: boolean; isSplittingIconSpritesheet: boolean; isPersistingAssetKind: boolean; onSplitIconSpritesheet: (layer: CanvasLayer) => void; @@ -37,6 +41,9 @@ export function ImageCanvasSelectedLayerToolbarView({ onOpenRedrawPanel, onOpenCropExpandPanel, onRemoveBackground, + onPerfectPixel, + isPerfectPixelProcessing, + isPerfectPixelPendingConfirmation, isSplittingIconSpritesheet, isPersistingAssetKind, onSplitIconSpritesheet, @@ -120,6 +127,57 @@ export function ImageCanvasSelectedLayerToolbarView({ icon={ImageOff} onClick={() => onRemoveBackground(selectedLayer)} /> + + ) : ( + + ) + } + // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 + // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 + // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 + // 与相邻的拆分图集按钮保持同一套门禁。 + disabled={ + isPersistingAssetKind || + isPerfectPixelProcessing || + isPerfectPixelPendingConfirmation + } + aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} + onClick={() => onPerfectPixel(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isPerfectPixelProcessing + ? '处理中' + : isPerfectPixelPendingConfirmation + ? '待确认' + : '完美像素'} + + ) : null} {selectedLayer.assetKind === 'icon-spritesheet' ? ( diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 1a639d507..035baf71d 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -79,6 +79,8 @@ export type ImageCanvasStageViewProps = { generationComposerStyle: CSSProperties | null; selectedToolbarStyle: CSSProperties | null; splittingIconSpritesheetLayerIds?: ReadonlySet; + perfectPixelLayerIds?: ReadonlySet; + pendingPerfectPixelLayerIds?: ReadonlySet; persistingAssetKindLayerIds?: ReadonlySet; uploadDropTarget: 'canvas' | 'assets' | null; contextMenu: CanvasContextMenuState | null; @@ -149,6 +151,7 @@ export type ImageCanvasStageViewProps = { onOpenRedrawPanel: (layer: CanvasLayer) => void; onOpenCropExpandPanel: (layer: CanvasLayer) => void; onRemoveBackground: (layer: CanvasLayer) => void; + onPerfectPixel: (layer: CanvasLayer) => void; onSplitIconSpritesheet: (layer: CanvasLayer) => void; onExtractUiDesignAssets: (layer: CanvasLayer) => void; onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void; @@ -236,6 +239,8 @@ export function ImageCanvasStageView({ generationComposerStyle, selectedToolbarStyle, splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET, + perfectPixelLayerIds = EMPTY_LAYER_ID_SET, + pendingPerfectPixelLayerIds = EMPTY_LAYER_ID_SET, persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET, uploadDropTarget, contextMenu, @@ -285,6 +290,7 @@ export function ImageCanvasStageView({ onOpenRedrawPanel, onOpenCropExpandPanel, onRemoveBackground, + onPerfectPixel, onSplitIconSpritesheet, onExtractUiDesignAssets, onUiAssetExtractionToolChange, @@ -409,10 +415,18 @@ export function ImageCanvasStageView({ isPersistingAssetKind={Boolean( selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id), )} + isPerfectPixelProcessing={Boolean( + selectedLayer && perfectPixelLayerIds.has(selectedLayer.id), + )} + isPerfectPixelPendingConfirmation={Boolean( + selectedLayer && + pendingPerfectPixelLayerIds.has(selectedLayer.id), + )} onOpenQuickEditPanel={onOpenQuickEditPanel} onOpenRedrawPanel={onOpenRedrawPanel} onOpenCropExpandPanel={onOpenCropExpandPanel} onRemoveBackground={onRemoveBackground} + onPerfectPixel={onPerfectPixel} onSplitIconSpritesheet={onSplitIconSpritesheet} onExtractUiDesignAssets={onExtractUiDesignAssets} onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel} diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 446a3fa26..a4c1ed6b6 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -226,7 +226,8 @@ describe('ImageCanvasWorldView', () => { useResolvedAssetReadUrlMock.mockImplementation( (_source: string, options?: { objectKey?: string | null }) => ({ resolvedUrl: - options?.objectKey === 'generated-character-drafts/editor/uploaded.png' + options?.objectKey === + 'generated-character-drafts/editor/uploaded.png' ? 'https://oss.example.com/uploaded.png?signature=1' : _source, isResolving: false, @@ -345,7 +346,9 @@ describe('ImageCanvasWorldView', () => { expect(screen.getByText('生成中')).toBeTruthy(); const world = document.querySelector('.image-canvas-editor__world'); - const marquee = world?.querySelector('.image-canvas-editor__canvas-marquee'); + const marquee = world?.querySelector( + '.image-canvas-editor__canvas-marquee', + ); expect(world?.getAttribute('style')).toContain( 'transform: translate(10px, 20px) scale(1.5)', @@ -378,7 +381,11 @@ describe('ImageCanvasWorldView', () => { expect(within(frame).getByText('Icon Generator')).toBeTruthy(); expect(within(frame).getByText('图标')).toBeTruthy(); expect(within(frame).getByText('1024 x 768')).toBeTruthy(); - expect(within(frame).getByRole('status').textContent).toBe('生成中'); + const generatingStatus = within(frame).getByRole('status'); + expect(generatingStatus.textContent).toBe('生成中'); + expect(generatingStatus.className).not.toContain( + 'image-canvas-editor__generation-frame-progress--pending-confirmation', + ); fireEvent.pointerDown(frame); fireEvent.doubleClick(frame); @@ -394,6 +401,30 @@ describe('ImageCanvasWorldView', () => { expect(screen.queryByText('dialog-without-placeholder')).toBeNull(); }); + it('keeps a pending perfect-pixel placeholder visible with a confirmation verdict', () => { + const dialog = createGenerationDialog({ + id: 'dialog-perfect-pixel-pending', + status: 'pending-confirmation', + }); + + renderWorldView({ + canvasGenerationDialogs: [dialog], + generateDialog: null, + }); + + const frame = screen.getByRole('button', { name: '图像生成占位图' }); + expect(frame.className).toContain( + 'image-canvas-editor__generation-frame--pending-confirmation', + ); + expect(within(frame).getByText('Image Generator')).toBeTruthy(); + const pendingStatus = within(frame).getByRole('status'); + expect(pendingStatus.textContent).toBe('结果待确认'); + expect(pendingStatus.className).toContain( + 'image-canvas-editor__generation-frame-progress--pending-confirmation', + ); + expect(within(frame).queryByText('生成中')).toBeNull(); + }); + it('renders crop-expand frame handles and forwards drag actions', () => { const layer = createLayer(); const { props } = renderWorldView({ @@ -530,7 +561,14 @@ describe('ImageCanvasWorldView', () => { }, ])( 'renders $mode generation placeholder with its own icon and badge', - ({ mode, ariaLabel, generatorLabel, badgeLabel, frameClassName, iconClassName }) => { + ({ + mode, + ariaLabel, + generatorLabel, + badgeLabel, + frameClassName, + iconClassName, + }) => { const dialog = createGenerationDialog({ id: `dialog-${mode}`, mode: mode as CanvasGenerationDialogState['mode'], @@ -614,9 +652,9 @@ describe('ImageCanvasWorldView', () => { const inverseScale = '4'; expect( - (within(frame).getByText(generatorLabel) as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText(generatorLabel) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( mode === 'audio-sound-effect' || mode === 'audio-background-music' @@ -630,9 +668,9 @@ describe('ImageCanvasWorldView', () => { : inverseScale, ); expect( - (within(frame).getByText(badgeLabel) as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText(badgeLabel) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); }, ); @@ -718,14 +756,14 @@ describe('ImageCanvasWorldView', () => { const inverseScale = '4'; expect( - (within(layerButton).getByText('角色') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(layerButton).getByText('角色') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(layerButton).getByText('640 x 480 px') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(layerButton).getByText('640 x 480 px') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( within(layerButton) @@ -733,14 +771,14 @@ describe('ImageCanvasWorldView', () => { .style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(frame).getByText('Image Generator') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText('Image Generator') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText('1024 x 768') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); }); @@ -755,8 +793,11 @@ describe('ImageCanvasWorldView', () => { const frame = screen.getByRole('button', { name: '图像生成占位图' }); expect( - (frame.querySelector('.image-canvas-editor__generation-frame-icon') as HTMLElement) - .style.getPropertyValue('--image-canvas-editor-inverse-scale'), + ( + frame.querySelector( + '.image-canvas-editor__generation-frame-icon', + ) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe('4'); }); @@ -813,9 +854,9 @@ describe('ImageCanvasWorldView', () => { name: '查看角色主图图片信息', }); - expect(metadataButton.querySelector('svg')?.getAttribute('class')).toContain( - 'lucide-info', - ); + expect( + metadataButton.querySelector('svg')?.getAttribute('class'), + ).toContain('lucide-info'); }); it('renders audio layers with an audio control card instead of an image', () => { @@ -849,7 +890,8 @@ describe('ImageCanvasWorldView', () => { within(layerButton).getAllByRole('button', { name: /播放|暂停/u }), ).toHaveLength(1); expect( - within(layerButton).getByRole('button', { name: '播放游戏音效' }) + within(layerButton) + .getByRole('button', { name: '播放游戏音效' }) .querySelector('svg') ?.getAttribute('class'), ).toContain('lucide-volume-2'); @@ -872,9 +914,7 @@ describe('ImageCanvasWorldView', () => { ).toBeTruthy(); expect(within(layerButton).getByLabelText('静音游戏音效')).toBeTruthy(); expect(within(layerButton).getByLabelText('调整游戏音效音量')).toBeTruthy(); - expect( - within(layerButton).queryByText('420 x 120 px'), - ).toBeNull(); + expect(within(layerButton).queryByText('420 x 120 px')).toBeNull(); }); it('keeps audio controls interactive and switches icon, play and pause states', () => { @@ -895,7 +935,9 @@ describe('ImageCanvasWorldView', () => { selectedLayerIds: [layer.id], }); - const layerButton = screen.getByRole('button', { name: '选择游戏背景音乐' }); + const layerButton = screen.getByRole('button', { + name: '选择游戏背景音乐', + }); const audio = within(layerButton).getByLabelText('画布音频:游戏背景音乐'); const playbackButton = within(layerButton).getByRole('button', { name: '播放游戏背景音乐', @@ -932,9 +974,8 @@ describe('ImageCanvasWorldView', () => { ?.getAttribute('class'), ).toContain('lucide-play'); - const hoveredAudio = within(hoveredLayerButton).getByLabelText( - '画布音频:游戏背景音乐', - ); + const hoveredAudio = + within(hoveredLayerButton).getByLabelText('画布音频:游戏背景音乐'); fireEvent.play(hoveredAudio); expect( within(hoveredLayerButton) @@ -1032,9 +1073,7 @@ describe('ImageCanvasWorldView', () => { expect(screen.getByRole('menu', { name: '选择素材标签' })).toBeTruthy(); fireEvent.pointerDown(document.body); - expect( - screen.queryByRole('menu', { name: '选择素材标签' }), - ).toBeNull(); + expect(screen.queryByRole('menu', { name: '选择素材标签' })).toBeNull(); } finally { document.removeEventListener('wheel', documentWheel); } @@ -1100,7 +1139,9 @@ describe('ImageCanvasWorldView', () => { layerButton.querySelector('.image-canvas-editor__media-preview--video') ?.className, ).toContain('image-canvas-editor__media-preview--variant-'); - expect(within(layerButton).queryByAltText('画布视频:生成视频 7')).toBeNull(); + expect( + within(layerButton).queryByAltText('画布视频:生成视频 7'), + ).toBeNull(); expect(within(layerButton).getByText('视频')).toBeTruthy(); expect(props.onLayerPointerDown).toHaveBeenCalledWith( expect.any(Object), @@ -1201,8 +1242,7 @@ describe('ImageCanvasWorldView', () => { it('keeps the last loaded sequence frame visible while the next frame is resolving', () => { vi.useFakeTimers(); try { - useResolvedAssetReadUrlMock.mockImplementation( - (source: string) => ({ + useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({ resolvedUrl: source === '/generated-character-drafts/editor/frame01.png' ? 'https://oss.example.com/frame01.png?signature=1' @@ -1210,8 +1250,7 @@ describe('ImageCanvasWorldView', () => { isResolving: source === '/generated-character-drafts/editor/frame02.png', shouldResolve: true, - }), - ); + })); const layer = createLayer({ title: '角色动作', src: '/generated-character-drafts/editor/frame01.png', @@ -1240,9 +1279,8 @@ describe('ImageCanvasWorldView', () => { renderWorldView({ layers: [layer] }); const layerButton = screen.getByRole('button', { name: '选择角色动作' }); - const firstFrame = within(layerButton).getByAltText( - '画布序列帧:角色动作', - ); + const firstFrame = + within(layerButton).getByAltText('画布序列帧:角色动作'); fireEvent.load(firstFrame); expect( @@ -1292,8 +1330,6 @@ describe('ImageCanvasWorldView', () => { }); expect(screen.getByRole('button', { name: '选择生成图片' })).toBeTruthy(); - expect( - screen.queryByRole('button', { name: '图像生成占位图' }), - ).toBeNull(); + expect(screen.queryByRole('button', { name: '图像生成占位图' })).toBeNull(); }); }); diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 97ef196df..bdb56afb5 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -316,9 +316,7 @@ function selectMediaLayerWithoutDrag( onSelectLayer(); } -function handleLayerKeyboardActivation( - event: ReactKeyboardEvent, -) { +function handleLayerKeyboardActivation(event: ReactKeyboardEvent) { if (event.key !== 'Enter' && event.key !== ' ') { return; } @@ -364,7 +362,9 @@ function getAudioLayerIcon(layer: CanvasLayer) { ); } -function getAudioDurationSeconds(durationSeconds: CanvasLayer['durationSeconds']) { +function getAudioDurationSeconds( + durationSeconds: CanvasLayer['durationSeconds'], +) { return typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 @@ -485,7 +485,9 @@ function ImageCanvasAudioLayerCard({ event.stopPropagation(); void togglePlayback(); }} - onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)} + onPointerDown={(event) => + selectMediaLayerWithoutDrag(event, onSelectLayer) + } > {showPlaybackButton ? ( isPlaying ? ( @@ -504,7 +506,9 @@ function ImageCanvasAudioLayerCard({ className="image-canvas-editor__audio-card-controls image-canvas-editor__audio-card-controls--integrated" onClick={stopMediaEventPropagation} onKeyDown={stopMediaControlKeyPropagation} - onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)} + onPointerDown={(event) => + selectMediaLayerWithoutDrag(event, onSelectLayer) + } >
); })() diff --git a/src/components/image-editor/perfectPixelOperationStore.test.ts b/src/components/image-editor/perfectPixelOperationStore.test.ts new file mode 100644 index 000000000..8860263df --- /dev/null +++ b/src/components/image-editor/perfectPixelOperationStore.test.ts @@ -0,0 +1,228 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes'; +import { + forgetPerfectPixelOperation, + PERFECT_PIXEL_OPERATION_RETENTION_LIMIT, + PERFECT_PIXEL_OPERATION_RETENTION_MS, + readPerfectPixelOperations, + savePerfectPixelOperation, +} from './perfectPixelOperationStore'; + +const OWNER_USER_ID = 'user-a'; +const PROJECT_ID = 'project-1'; +const NOW = 1_785_715_270_000; + +function buildOperation( + dialogId: string, + overrides: Partial = {}, +): PerfectPixelOperationSnapshot { + return { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'ref:project-resource:resource-source', + projectId: PROJECT_ID, + sourceResourceId: 'resource-source', + assetKind: 'character', + assetLabel: '角色 · 完美像素', + canvasCompletion: { + dialogId, + title: '角色 · 完美像素', + placeholder: { + x: 100, + y: 120, + width: 320, + height: 320, + originalWidth: 640, + originalHeight: 640, + }, + }, + }, + submittedAt: NOW, + reconcileUntil: NOW + 75_000, + ...overrides, + }; +} + +describe('perfectPixelOperationStore', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('round-trips an operation for the same owner and project', () => { + const operation = buildOperation('dialog-1'); + savePerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, operation); + + const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); + + expect(ledger.get('dialog-1')).toEqual(operation); + }); + + it('keeps ledgers separated by project and drops another owner entirely', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ); + + expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0); + // 中文注释:同一台机器换账号后,上一个账号的请求(含源图直传地址)必须整条丢弃。 + expect(readPerfectPixelOperations('user-b', PROJECT_ID).size).toBe(0); + expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0); + }); + + it('refuses to store an operation whose request targets another project', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + 'project-2', + buildOperation('dialog-1'), + ); + + expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0); + }); + + it('fails closed on a tampered ledger entry instead of replaying it', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ); + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-2'), + ); + const key = `genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`; + const stored = JSON.parse(window.localStorage.getItem(key)!) as { + ownerUserId: string; + operations: Record; + }; + stored.operations['dialog-1']!.taskId = 'pixel-art-snap-somewhere-else'; + window.localStorage.setItem(key, JSON.stringify(stored)); + + const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); + + expect(ledger.has('dialog-1')).toBe(false); + expect(ledger.has('dialog-2')).toBe(true); + }); + + it('drops entries past the retention window', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-old', { + submittedAt: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS - 1, + reconcileUntil: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS + 74_999, + }), + ); + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-fresh'), + ); + + const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); + + expect([...ledger.keys()]).toEqual(['dialog-fresh']); + }); + + it('caps the ledger size by keeping the newest submissions', () => { + for ( + let index = 0; + index <= PERFECT_PIXEL_OPERATION_RETENTION_LIMIT; + index += 1 + ) { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation(`dialog-${index}`, { + submittedAt: NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index), + reconcileUntil: + NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index) + 75_000, + }), + ); + } + + const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); + + expect(ledger.size).toBe(PERFECT_PIXEL_OPERATION_RETENTION_LIMIT); + expect(ledger.has('dialog-0')).toBe(false); + expect( + ledger.has(`dialog-${PERFECT_PIXEL_OPERATION_RETENTION_LIMIT}`), + ).toBe(true); + }); + + it('forgets a settled operation and clears the key once empty', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ); + + forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1'); + + expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0); + expect( + window.localStorage.getItem( + `genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`, + ), + ).toBeNull(); + }); + + it('degrades to an empty ledger without throwing when storage is unavailable', () => { + const setItem = vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + const getItem = vi + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(() => { + throw new Error('SecurityError'); + }); + + try { + // 中文注释:隐私模式 / 配额写满时账本读写都会抛。这里必须静默降级——账本缺失只 + // 意味着刷新后不能自动收口,绝不能反过来阻断发起、重试或删除。 + expect(() => + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ), + ).not.toThrow(); + expect(() => + forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1'), + ).not.toThrow(); + expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe( + 0, + ); + } finally { + setItem.mockRestore(); + getItem.mockRestore(); + } + }); + + it('returns an empty ledger without an owner or project', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ); + + expect(readPerfectPixelOperations(null, PROJECT_ID).size).toBe(0); + expect(readPerfectPixelOperations(OWNER_USER_ID, ' ').size).toBe(0); + }); +}); diff --git a/src/components/image-editor/perfectPixelOperationStore.ts b/src/components/image-editor/perfectPixelOperationStore.ts new file mode 100644 index 000000000..324841c85 --- /dev/null +++ b/src/components/image-editor/perfectPixelOperationStore.ts @@ -0,0 +1,203 @@ +import { hydratePerfectPixelOperation } from './ImageCanvasEditorModel'; +import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes'; + +/** + * 中文注释:完美像素操作账本的本机存储。 + * + * **这是明确设计,不是降级方案**:账本记录的是「本机这次会话发出过哪一次 POST」, + * 它是对账凭据,不是用户的画布内容,因此不进项目布局。由此得到两条硬性质: + * + * 1. **写入同步、不依赖网络、不依赖服务端校验。** 发 POST 前先落本机账本即可获得 + * 「请求可被追溯」的保证,不必再用严格布局保存去换同一个保证。布局校验(例如 + * 资源元数据读写不对称)从此不可能阻断完美像素的发起或重试。 + * 2. **账本缺失只降级、绝不阻断。** 换设备、换浏览器、清缓存、隐私模式、配额写满, + * 都会读不到账本。那种情况下占位收口为可删除的失败态,用户可以删掉重来; + * 任何路径都不得因为「读不到账本」而拒绝用户发起、重试或删除。 + * + * 代价是跨设备不再自动收口:在 A 机发起、到 B 机打开同一项目时,B 机看到的是失败占位 + * 而不是对账中的占位。完美像素是免费同步操作,重做成本极低,用这点换掉「用户数据里 + * 混着系统对账状态」的耦合是划算的。 + */ + +const PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX = + 'genarrative.imageCanvas.perfectPixelOperations'; + +/** + * 中文注释:账本保留期。对账窗口只有 75 秒,但 `pending-confirmation` 占位允许用户在很久 + * 之后手动重试同一次 operation,那条路径同样需要账本,所以保留期必须远长于对账窗口。 + */ +export const PERFECT_PIXEL_OPERATION_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000; + +/** + * 中文注释:单个项目最多保留的账本条数,超出时丢弃最旧的。防止长期使用把 localStorage + * 配额吃满——配额写满会连带影响同域下其它本地缓存,而账本本身是可丢弃的。 + */ +export const PERFECT_PIXEL_OPERATION_RETENTION_LIMIT = 32; + +type PerfectPixelOperationLedger = Map; + +function getPerfectPixelOperationStorage() { + if (typeof window === 'undefined') { + return null; + } + try { + return window.localStorage; + } catch { + return null; + } +} + +function perfectPixelOperationStorageKey(projectId: string | null | undefined) { + const normalizedProjectId = projectId?.trim(); + if (!normalizedProjectId) { + return null; + } + return `${PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX}.${normalizedProjectId}`; +} + +/** + * 中文注释:读账本时同时校验归属。同一台机器可能先后登录不同账号,账本里带着上一个 + * 账号的请求(含源图直传地址),换人后必须整条丢弃而不是原样返回。 + */ +function readLedgerEntries( + storage: Storage, + key: string, + ownerUserId: string, +): PerfectPixelOperationLedger { + const ledger: PerfectPixelOperationLedger = new Map(); + const rawValue = storage.getItem(key); + if (!rawValue) { + return ledger; + } + const parsedValue: unknown = JSON.parse(rawValue); + if (!parsedValue || typeof parsedValue !== 'object') { + return ledger; + } + const record = parsedValue as Record; + const recordOwnerUserId = + typeof record.ownerUserId === 'string' ? record.ownerUserId.trim() : ''; + if (recordOwnerUserId !== ownerUserId) { + storage.removeItem(key); + return ledger; + } + const operations = record.operations; + if (!operations || typeof operations !== 'object') { + return ledger; + } + const now = Date.now(); + for (const [operationId, value] of Object.entries( + operations as Record, + )) { + // 中文注释:本机账本与布局快照走同一套 v1 白名单校验。存储可被用户或其它脚本改写, + // 任何字段漂移都必须失败关闭——绝不能据一份可疑账本重放 POST。 + const operation = hydratePerfectPixelOperation(value, operationId); + if (!operation) { + continue; + } + if (now - operation.submittedAt > PERFECT_PIXEL_OPERATION_RETENTION_MS) { + continue; + } + ledger.set(operationId, operation); + } + return ledger; +} + +function writeLedgerEntries( + storage: Storage, + key: string, + ownerUserId: string, + ledger: PerfectPixelOperationLedger, +) { + if (ledger.size === 0) { + storage.removeItem(key); + return; + } + const retained = [...ledger.values()] + .sort((left, right) => right.submittedAt - left.submittedAt) + .slice(0, PERFECT_PIXEL_OPERATION_RETENTION_LIMIT); + storage.setItem( + key, + JSON.stringify({ + ownerUserId, + operations: Object.fromEntries( + retained.map((operation) => [operation.operationId, operation]), + ), + }), + ); +} + +/** + * 中文注释:读取某项目在本机的全部有效账本。任何异常都返回空账本——读不到账本只意味着 + * 「刷新后不能自动收口」,调用方必须能在空账本下继续工作。 + */ +export function readPerfectPixelOperations( + currentUserId: string | null | undefined, + projectId: string | null | undefined, +): PerfectPixelOperationLedger { + const ownerUserId = currentUserId?.trim(); + const key = perfectPixelOperationStorageKey(projectId); + const storage = getPerfectPixelOperationStorage(); + if (!ownerUserId || !key || !storage) { + return new Map(); + } + try { + return readLedgerEntries(storage, key, ownerUserId); + } catch { + return new Map(); + } +} + +/** + * 中文注释:写入一条账本。必须在发 POST 之前调用——这是整条链路里唯一「请求已发出」的 + * 本地证据。写入失败(配额、隐私模式)同样不阻断:调用方照常发 POST,只是丢掉刷新后 + * 自动收口的能力。 + */ +export function savePerfectPixelOperation( + currentUserId: string | null | undefined, + projectId: string | null | undefined, + operation: PerfectPixelOperationSnapshot, +) { + const ownerUserId = currentUserId?.trim(); + const key = perfectPixelOperationStorageKey(projectId); + const storage = getPerfectPixelOperationStorage(); + if (!ownerUserId || !key || !storage) { + return; + } + if (operation.request.projectId !== projectId?.trim()) { + return; + } + try { + const ledger = readLedgerEntries(storage, key, ownerUserId); + ledger.set(operation.operationId, operation); + writeLedgerEntries(storage, key, ownerUserId, ledger); + } catch { + // 中文注释:账本是尽力而为的本机便利,写失败不得影响本次提交。 + } +} + +/** + * 中文注释:操作收口(结果已套用 / 只落素材库 / 快照判定失效)后清账本,避免过期条目 + * 在下次加载时再发一次无谓的对账 GET。 + */ +export function forgetPerfectPixelOperation( + currentUserId: string | null | undefined, + projectId: string | null | undefined, + operationId: string, +) { + const ownerUserId = currentUserId?.trim(); + const key = perfectPixelOperationStorageKey(projectId); + const storage = getPerfectPixelOperationStorage(); + const normalizedOperationId = operationId.trim(); + if (!ownerUserId || !key || !storage || !normalizedOperationId) { + return; + } + try { + const ledger = readLedgerEntries(storage, key, ownerUserId); + if (!ledger.delete(normalizedOperationId)) { + return; + } + writeLedgerEntries(storage, key, ownerUserId, ledger); + } catch { + // 中文注释:清理失败最多留下一条过期账本,保留期会兜底。 + } +} diff --git a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx index fba896056..eeb2d117f 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx +++ b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx @@ -1,10 +1,13 @@ /* @vitest-environment jsdom */ -import { act,renderHook } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; -import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; +import { + requiresGenerationDeleteConfirmation, + useCanvasGenerationDialogs, +} from './useCanvasGenerationDialogs'; function createDialog( mode: CanvasGenerationDialogState['mode'], @@ -26,6 +29,48 @@ function createDialog( }; } +function durablePerfectPixelDialog( + dialogId: string, + status: 'generating' | 'pending-confirmation' | 'failed', +): CanvasGenerationDialogState { + const submittedAt = 1_700_000_000_000; + return { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status, + composerOpen: true, + sourceLayerId: 'layer-source', + // 中文注释:marker 从占位创建那一刻就存在,账本形成后也一直在。夹具必须还原这个形状, + // 否则下游用例是在测一个真实链路里不存在的状态。 + perfectPixelOperationId: dialogId, + perfectPixelOperation: { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'generated-images/editor/source.png', + projectId: 'project-1', + canvasCompletion: { + dialogId, + title: '源图 · 完美像素', + placeholder: { + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }, + submittedAt, + reconcileUntil: submittedAt + 75_000, + }, + }; +} + describe('useCanvasGenerationDialogs', () => { it('archives, activates, updates, and removes canvas generation dialogs', () => { const onActivate = vi.fn(); @@ -280,4 +325,116 @@ describe('useCanvasGenerationDialogs', () => { }), ]); }); + + it('returns a newly opened explicit dialog from the synchronous snapshot getter in the same action', () => { + const { result } = renderHook(() => useCanvasGenerationDialogs()); + let openedDialogId = ''; + let immediateSnapshot: CanvasGenerationDialogState[] = []; + + act(() => { + openedDialogId = result.current.openCanvasGenerationDialog({ + ...createDialog('quick-edit', '完美像素'), + id: 'generation-dialog-perfect-pixel', + status: 'generating', + }); + immediateSnapshot = result.current.getCanvasGenerationDialogsSnapshot(); + }); + + expect(openedDialogId).toBe('generation-dialog-perfect-pixel'); + expect(immediateSnapshot).toEqual([ + expect.objectContaining({ + id: 'generation-dialog-perfect-pixel', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + }), + ]); + }); + + // 中文注释:占位是用户文档的一部分,删除它不撤销任何在途请求——完美像素没有取消接口, + // 结果照常落库并进素材库。低层删除因此不得对未收口 operation 抗命,否则上层会写出 + // 「历史记了一笔、占位还在」的伪历史。 + it.each(['generating', 'pending-confirmation', 'failed'] as const)( + 'deletes an unsettled durable perfect-pixel operation by id while %s', + (status) => { + const { result } = renderHook(() => useCanvasGenerationDialogs()); + const dialogId = 'perfect-pixel-durable'; + + act(() => { + result.current.restoreCanvasGenerationDialogs([ + durablePerfectPixelDialog(dialogId, status), + ]); + }); + act(() => { + result.current.removeCanvasGenerationDialogById(dialogId); + }); + + expect(result.current.activeCanvasGenerationDialog).toBeNull(); + expect(result.current.canvasGenerationDialogs).toEqual([]); + }, + ); + + // 中文注释:确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立;完美像素免费且删除 + // 占位不撤销在途请求,所以它在任何状态都直接删。 + it.each(['generating', 'pending-confirmation', 'failed'] as const)( + 'never asks for delete confirmation on a perfect-pixel placeholder while %s', + (status) => { + expect( + requiresGenerationDeleteConfirmation( + durablePerfectPixelDialog('perfect-pixel-confirm', status), + ), + ).toBe(false); + }, + ); + + it('never asks for delete confirmation before the perfect-pixel ledger exists', () => { + // 中文注释:源图解析 / 直传最长 90 秒,期间占位只有 marker、还没有账本。此前判据按账本 + // 判,用户在这段窗口里删一个免费操作会看到「已消耗的泥点不会返还」。 + expect( + requiresGenerationDeleteConfirmation({ + id: 'perfect-pixel-preparing', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + composerOpen: false, + sourceLayerId: 'layer-source', + requiresLiveSession: true, + perfectPixelOperationId: 'perfect-pixel-preparing', + }), + ).toBe(false); + }); + + it('still asks for delete confirmation on an ordinary generating placeholder', () => { + expect( + requiresGenerationDeleteConfirmation({ + id: 'generation-dialog-1', + ...createDialog('generate', '一只猫'), + status: 'generating', + }), + ).toBe(true); + expect( + requiresGenerationDeleteConfirmation({ + id: 'generation-dialog-2', + ...createDialog('generate', '一只猫'), + status: 'failed', + }), + ).toBe(false); + }); + + it('drops unsettled durable perfect-pixel operations together with their source layer', () => { + const { result } = renderHook(() => useCanvasGenerationDialogs()); + const dialogId = 'perfect-pixel-durable'; + + act(() => { + result.current.restoreCanvasGenerationDialogs([ + durablePerfectPixelDialog(dialogId, 'pending-confirmation'), + ]); + }); + act(() => { + result.current.removeCanvasGenerationDialogsByLayerId('layer-source'); + }); + + expect(result.current.activeCanvasGenerationDialog).toBeNull(); + expect(result.current.canvasGenerationDialogs).toEqual([]); + }); }); diff --git a/src/components/image-editor/useCanvasGenerationDialogs.ts b/src/components/image-editor/useCanvasGenerationDialogs.ts index c386825e7..bbcdd69cb 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.ts +++ b/src/components/image-editor/useCanvasGenerationDialogs.ts @@ -17,6 +17,35 @@ type CanvasGenerationDialogUpdater = ( dialog: CanvasGenerationDialogState, ) => CanvasGenerationDialogState | null; +export type CanvasGenerationDialogDraft = Omit< + CanvasGenerationDialogState, + 'id' +> & { + id?: string; +}; + +/** + * 中文注释:删除生成占位前是否需要二次确认。 + * + * 确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立。完美像素 + * `generation_cost_mud_points = 0`,删除占位也不撤销任何在途请求——它没有取消接口,结果照常 + * 落库并进素材库,服务端 completion 发现 dialog 已不在会返回 DialogMissing 并由客户端提示。 + * 所以完美像素占位在任何状态都直接删,不额外解释。 + * + * 判据必须看 `perfectPixelOperationId` 而**不是** `perfectPixelOperation`:后者要到源图解析 / + * 直传完成后才写入,那一段预算最长 90 秒(未登记的本地图片要走 ticket → PUT → confirm), + * 期间占位是 `generating` 且没有账本,按账本判会让用户删一个免费操作时看到「已消耗的泥点 + * 不会返还」。marker 在占位创建那一刻就写上,覆盖完整生命周期。 + * + * 更一般地:这里问的是「**这次生成计不计费**」,账本的有无只是它在某一段时间内的代理。 + * 用短寿命字段的存在性去判断长期属性,正是本仓库反复出错的形状。 + */ +export function requiresGenerationDeleteConfirmation( + dialog: CanvasGenerationDialogState, +) { + return dialog.status === 'generating' && !dialog.perfectPixelOperationId; +} + function withGenerationTimestamps( nextDialog: T, previousDialog?: GenerateDialogState | null, @@ -90,6 +119,14 @@ export function useCanvasGenerationDialogs({ [activeCanvasGenerationDialog, inactiveGenerateDialogs], ); + const getCanvasGenerationDialogsSnapshot = useCallback(() => { + const currentDialog = generateDialogRef.current; + return [ + ...inactiveGenerateDialogsRef.current, + ...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []), + ]; + }, []); + const createGenerationDialogId = useCallback(() => { generationDialogCounterRef.current += 1; return `generation-dialog-${generationDialogCounterRef.current}`; @@ -116,7 +153,7 @@ export function useCanvasGenerationDialogs({ }, []); const openCanvasGenerationDialog = useCallback( - (dialog: Omit) => { + (dialog: CanvasGenerationDialogDraft) => { const currentDialog = generateDialogRef.current; if (isCanvasGenerationDialog(currentDialog)) { inactiveGenerateDialogsRef.current = @@ -133,16 +170,30 @@ export function useCanvasGenerationDialogs({ ]; } archiveActiveCanvasGenerationDialog(); - const id = createGenerationDialogId(); + const requestedId = dialog.id?.trim(); + const requestedIdAlreadyExists = + Boolean(requestedId) && + getCanvasGenerationDialogsSnapshot().some( + (existingDialog) => existingDialog.id === requestedId, + ); + const id = + requestedId && !requestedIdAlreadyExists + ? requestedId + : createGenerationDialogId(); + const { id: _requestedId, ...dialogWithoutId } = dialog; const nextDialog = withGenerationTimestamps({ - ...dialog, + ...dialogWithoutId, id, }); generateDialogRef.current = nextDialog; setGenerateDialogState(nextDialog); return id; }, - [archiveActiveCanvasGenerationDialog, createGenerationDialogId], + [ + archiveActiveCanvasGenerationDialog, + createGenerationDialogId, + getCanvasGenerationDialogsSnapshot, + ], ); const updateCanvasGenerationDialogById = useCallback( @@ -187,6 +238,10 @@ export function useCanvasGenerationDialogs({ [], ); + // 中文注释:低层删除不再对未收口的完美像素 operation 抗命。删除占位不撤销任何在途请求 + // ——完美像素没有取消接口,结果照常落库并进素材库,服务端 completion 发现 dialog 已不在 + // 会返回 DialogMissing,客户端有对应提示。封锁换来的只是「结果自动回填画布」这一便利, + // 代价却是用户画布上出现删不掉的对象;低层偷偷保留还会让上层写出伪历史。 const removeCanvasGenerationDialogById = useCallback( (dialogId: string) => { updateCanvasGenerationDialogById(dialogId, () => null); @@ -242,7 +297,9 @@ export function useCanvasGenerationDialogs({ nextCounter, ); const activeDialog = - [...dialogs].reverse().find((dialog) => dialog.composerOpen !== false) ?? + [...dialogs] + .reverse() + .find((dialog) => dialog.composerOpen !== false) ?? dialogs[dialogs.length - 1] ?? null; const nextActiveDialog = activeDialog @@ -318,6 +375,7 @@ export function useCanvasGenerationDialogs({ inactiveGenerateDialogsRef, activeCanvasGenerationDialog, canvasGenerationDialogs, + getCanvasGenerationDialogsSnapshot, archiveActiveCanvasGenerationDialog, openCanvasGenerationDialog, updateCanvasGenerationDialogById, diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 038ecf126..8f8759f7a 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -34,6 +34,7 @@ const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn()); const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() => vi.fn(), ); +const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn()); @@ -72,6 +73,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ + uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock, uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); @@ -500,6 +502,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { beforeEach(() => { resolveEditorImageReferenceDataUrlMock.mockReset(); resolveEditorImageReferenceDataUrlForGenerationMock.mockReset(); + uploadEditorMediaAssetObjectFileMock.mockReset(); uploadEditorMediaAssetFileMock.mockReset(); editEditorImageMock.mockReset(); extractEditorUiDesignAssetsMock.mockReset(); @@ -518,8 +521,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { resolveEditorImageReferenceDataUrlForGenerationMock.mockImplementation( async (src: string) => src, ); - uploadEditorMediaAssetFileMock.mockResolvedValue({ - src: 'https://signed.example.test/generation-reference.png', + uploadEditorMediaAssetObjectFileMock.mockResolvedValue({ objectKey: 'generated-character-drafts/editor/generation-references/reference.png', assetObjectId: 'asset-object-generation-reference', @@ -550,16 +552,56 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { dateNowSpy.mockRestore(); } - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(2); + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(2); const [firstFile, , firstOptions] = - uploadEditorMediaAssetFileMock.mock.calls[0] ?? []; + uploadEditorMediaAssetObjectFileMock.mock.calls[0] ?? []; const [secondFile, , secondOptions] = - uploadEditorMediaAssetFileMock.mock.calls[1] ?? []; + uploadEditorMediaAssetObjectFileMock.mock.calls[1] ?? []; expect((firstFile as File).name).not.toBe((secondFile as File).name); expect(firstOptions.pathSegments).not.toEqual(secondOptions.pathSegments); }); + it('reuses a caller supplied upload id for the same inline operation', async () => { + const options = { uploadId: 'perfect-pixel-operation-1' }; + + await resolveEditorGenerationMediaReference( + { src: 'data:image/png;base64,YQ==' }, + 'image', + 'project-1', + options, + ); + await resolveEditorGenerationMediaReference( + { src: 'data:image/png;base64,YQ==' }, + 'image', + 'project-1', + options, + ); + + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(2); + for (const [ + file, + mediaType, + uploadOptions, + ] of uploadEditorMediaAssetObjectFileMock.mock.calls) { + expect((file as File).name).toBe( + 'generation-reference-perfect-pixel-operation-1.png', + ); + expect(mediaType).toBe('image'); + expect(uploadOptions).toEqual( + expect.objectContaining({ + pathSegments: [ + 'editor', + 'generation-references', + 'project-1', + 'perfect-pixel-operation-1', + ], + }), + ); + } + }); + it('uploads image references without an object reference before generation', async () => { + const controller = new AbortController(); resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce( 'data:image/png;base64,ZXhhbXBsZQ==', ); @@ -567,23 +609,124 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { { src: '/creation-type-references/example.webp' }, 'image', 'project-1', + { + signal: controller.signal, + uploadId: 'perfect-pixel-operation-1', + }, ); expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith( '/creation-type-references/example.webp', + controller.signal, ); - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( expect.any(File), 'image', expect.objectContaining({ assetKind: 'editor_generation_reference_image', + signal: controller.signal, }), ); + expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled(); expect(result).toBe( 'generated-character-drafts/editor/generation-references/reference.png', ); }); + it('passes the abort signal through blob fetch and object registration', async () => { + const controller = new AbortController(); + const blobMock = vi + .fn() + .mockResolvedValue(new Blob(['video'], { type: 'video/mp4' })); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + blob: blobMock, + }); + vi.stubGlobal('fetch', fetchMock); + + try { + await resolveEditorGenerationMediaReference( + { src: 'blob:https://editor.example.test/reference-video' }, + 'video', + 'project-1', + { + signal: controller.signal, + uploadId: 'video-operation-1', + }, + ); + } finally { + vi.unstubAllGlobals(); + } + + expect(fetchMock).toHaveBeenCalledWith( + 'blob:https://editor.example.test/reference-video', + { signal: controller.signal }, + ); + expect(blobMock).toHaveBeenCalledTimes(1); + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( + expect.any(File), + 'video', + expect.objectContaining({ + pathSegments: [ + 'editor', + 'generation-references', + 'project-1', + 'video-operation-1', + ], + signal: controller.signal, + }), + ); + }); + + it('does not parse or upload an inline Data URL after cancellation', async () => { + const controller = new AbortController(); + controller.abort(new DOMException('已取消', 'AbortError')); + + await expect( + resolveEditorGenerationMediaReference( + { src: 'data:image/png;base64,YQ==' }, + 'image', + 'project-1', + { + signal: controller.signal, + uploadId: 'cancelled-operation', + }, + ), + ).rejects.toMatchObject({ name: 'AbortError' }); + + expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled(); + }); + + it('stops after image source parsing when cancellation wins the boundary', async () => { + const controller = new AbortController(); + let finishImageParsing!: (value: string) => void; + resolveEditorImageReferenceDataUrlMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishImageParsing = resolve; + }), + ); + + const resolution = resolveEditorGenerationMediaReference( + { src: '/creation-type-references/slow.webp' }, + 'image', + 'project-1', + { + signal: controller.signal, + uploadId: 'cancelled-after-parse', + }, + ); + controller.abort(new DOMException('已取消', 'AbortError')); + finishImageParsing('data:image/png;base64,YQ=='); + + await expect(resolution).rejects.toMatchObject({ name: 'AbortError' }); + expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith( + '/creation-type-references/slow.webp', + controller.signal, + ); + expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled(); + }); + it('submits quick edits and updates the source layer directly', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ @@ -738,9 +881,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); render( { ); }); await waitFor(() => { - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( expect.any(File), 'image', expect.objectContaining({ @@ -1002,7 +1143,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); expect( - uploadEditorMediaAssetFileMock.mock.invocationCallOrder[0], + uploadEditorMediaAssetObjectFileMock.mock.invocationCallOrder[0], ).toBeLessThan(editEditorImageMock.mock.invocationCallOrder[0] ?? 0); expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty( 'referenceImageSrcs', @@ -1126,8 +1267,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { prompt: '角色换成蓝色披风', warning: { code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }, }), ); @@ -1327,7 +1467,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( expect.any(File), 'video', expect.objectContaining({ @@ -1797,7 +1937,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); - expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled(); + expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled(); }); it('refreshes the wallet and shows the warning after a queued character generation completes', async () => { @@ -1821,8 +1961,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { prompt: '队列角色生成', }), queueState: createQueueState({ - warning: - '生成任务成功,后处理失败。', + warning: '生成任务成功,后处理失败。', }), }); render( @@ -1870,7 +2009,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); - it('reloads a queued project snapshot again when the completion dialog is still unresolved', async () => { + it('reloads a queued project snapshot when a later duplicate completion dialog is unresolved', async () => { vi.useFakeTimers(); try { const applyProjectSnapshot = vi.fn(); @@ -1881,6 +2020,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { title: '队列项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ + { + layerId: 'generation-dialog-background-removal-completed', + resourceId: 'generation-dialog-background-removal-completed', + itemType: 'generation-dialog', + dialog: { + id: 'dialog-background-removal', + status: 'idle', + generatedLayerId: 'layer-background-removal-result', + }, + }, { layerId: 'generation-dialog-background-removal', resourceId: 'generation-dialog-background-removal', @@ -1958,8 +2107,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { await applyQueuedEditorGenerationProject( { queueState: createQueueState({ - warning: - '生成任务成功,后处理失败。', + warning: '生成任务成功,后处理失败。', }), }, 'editor-project-1', @@ -2234,7 +2382,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); - expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled(); + expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled(); }); it('submits icon spec objects without requiring an icon spec reference', async () => { @@ -2616,8 +2764,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { taskId: 'task-ui-assets', warning: { code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }, }); render( @@ -2795,8 +2942,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { height: 768, warning: { code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }, }), ); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index ff4df781d..e1b3bd122 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -11,7 +11,7 @@ import { getExternalGenerationJobStatus } from '../../services/external-generati import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference'; import { type EditorMediaAssetUploadType, - uploadEditorMediaAssetFile, + uploadEditorMediaAssetObjectFile, } from '../../services/image-editor/editorMediaAssetUploadClient'; import type { EditorAssetSnapshot, @@ -29,6 +29,10 @@ import { generateEditorVideo, loadEditorProject, } from '../../services/image-editor/editorProjectClient'; +import { + findCanvasGenerationDialogRecords, + isUnresolvedCanvasGenerationDialogRecord, +} from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasGenerationInputs, @@ -110,6 +114,12 @@ type EditorGenerationMediaReference = { type EditorGenerationMediaReferenceOptions = { allowRegisteredIds?: boolean; requireImageObjectReference?: boolean; + // 中文注释:需要 unknown 重放的同步操作由调用方传入稳定 id;普通入口不传时仍为每次 + // 上传生成随机路径,避免并发参考图互相覆盖。 + uploadId?: string; + // 中文注释:由调用方的阶段预算驱动,并贯穿源读取、Data URL 转换以及 + // ticket → PUT → confirm,不能只在最外层停止 await。 + signal?: AbortSignal; }; let editorGenerationUploadFallbackCounter = 0; @@ -147,7 +157,16 @@ function resolveEditorGenerationMediaReferenceSource( ); } -function dataUrlToEditorGenerationFile(dataUrl: string, fileName: string) { +function throwIfEditorGenerationMediaUploadAborted(signal?: AbortSignal) { + signal?.throwIfAborted(); +} + +function dataUrlToEditorGenerationFile( + dataUrl: string, + fileName: string, + signal?: AbortSignal, +) { + throwIfEditorGenerationMediaUploadAborted(signal); const [header = '', payload = ''] = dataUrl.split(','); const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header); if (!mimeMatch) { @@ -155,10 +174,12 @@ function dataUrlToEditorGenerationFile(dataUrl: string, fileName: string) { } const type = mimeMatch[1] ?? 'image/png'; const binary = mimeMatch[2] ? atob(payload) : decodeURIComponent(payload); + throwIfEditorGenerationMediaUploadAborted(signal); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } + throwIfEditorGenerationMediaUploadAborted(signal); return new File([bytes], fileName, { type }); } @@ -166,18 +187,22 @@ async function inlineMediaSourceToEditorGenerationFile( source: string, mediaType: EditorMediaAssetUploadType, uploadId: string, + signal?: AbortSignal, ) { + throwIfEditorGenerationMediaUploadAborted(signal); const fileName = `generation-reference-${uploadId}.${ mediaType === 'video' ? 'mp4' : mediaType === 'audio' ? 'mp3' : 'png' }`; if (/^data:/iu.test(source)) { - return dataUrlToEditorGenerationFile(source, fileName); + return dataUrlToEditorGenerationFile(source, fileName, signal); } - const response = await fetch(source); + const response = await fetch(source, { signal }); + throwIfEditorGenerationMediaUploadAborted(signal); if (!response.ok) { throw new Error('读取本地生成参考素材失败'); } const blob = await response.blob(); + throwIfEditorGenerationMediaUploadAborted(signal); return new File([blob], fileName, { type: blob.type || `${mediaType}/*`, }); @@ -187,11 +212,19 @@ async function uploadEditorGenerationInlineMediaSource( source: string, mediaType: EditorMediaAssetUploadType, projectId?: string | null, + signal?: AbortSignal, + stableUploadId?: string | null, ) { const normalizedProjectId = projectId?.trim() || 'unscoped'; - const uploadId = createEditorGenerationMediaUploadId(); - const uploaded = await uploadEditorMediaAssetFile( - await inlineMediaSourceToEditorGenerationFile(source, mediaType, uploadId), + const uploadId = + stableUploadId?.trim() || createEditorGenerationMediaUploadId(); + const uploaded = await uploadEditorMediaAssetObjectFile( + await inlineMediaSourceToEditorGenerationFile( + source, + mediaType, + uploadId, + signal, + ), mediaType, { assetKind: `editor_generation_reference_${mediaType}`, @@ -202,6 +235,7 @@ async function uploadEditorGenerationInlineMediaSource( uploadId, ], entityId: normalizedProjectId, + signal, ...(projectId?.trim() ? { metadata: { editor_project_id: projectId.trim() } } : {}), @@ -216,6 +250,7 @@ export async function resolveEditorGenerationMediaReference( projectId?: string | null, options: EditorGenerationMediaReferenceOptions = {}, ) { + throwIfEditorGenerationMediaUploadAborted(options.signal); const resourceId = reference.resourceId?.trim(); const hasRegisteredReference = options.allowRegisteredIds !== false && @@ -236,13 +271,17 @@ export async function resolveEditorGenerationMediaReference( if (!inlineSource && !imageSourceRequiresUpload) { return source; } - const uploadSource = imageSourceRequiresUpload && !inlineSource - ? await resolveEditorImageReferenceDataUrl(source) - : source; + const uploadSource = + imageSourceRequiresUpload && !inlineSource + ? await resolveEditorImageReferenceDataUrl(source, options.signal) + : source; + throwIfEditorGenerationMediaUploadAborted(options.signal); return uploadEditorGenerationInlineMediaSource( uploadSource, mediaType, projectId, + options.signal, + options.uploadId, ); } @@ -417,25 +456,9 @@ function projectHasUnresolvedGenerationDialog( project: EditorProjectSnapshot, dialogId: string | null | undefined, ) { - const normalizedDialogId = dialogId?.trim(); - if (!normalizedDialogId) { - return false; - } - return project.layers.some((item) => { - if (item.itemType !== 'generation-dialog') { - return false; - } - const dialog = - item.dialog && typeof item.dialog === 'object' - ? (item.dialog as Record) - : null; - return ( - dialog?.id === normalizedDialogId && - (dialog.status === 'generating' || - typeof dialog.generatedLayerId !== 'string' || - dialog.generatedLayerId.trim() === '') - ); - }); + return findCanvasGenerationDialogRecords(project, dialogId).some( + isUnresolvedCanvasGenerationDialogRecord, + ); } function notifyWalletBalanceMayHaveChanged(callback?: () => void) { diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index fab101b69..7d60180ee 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -152,6 +152,7 @@ function GenerationSurfaceHarness() { activeCanvasGenerationDialog: activeCanvasDialog, canvasGenerationDialogs: dialogs.canvasGenerationDialogs, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, + activateCanvasGenerationDialog: dialogs.activateCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, archiveActiveCanvasGenerationDialog: diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index a95b296c7..eadf6cb82 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -74,6 +74,9 @@ type ImageCanvasGenerationSurfaceOptions = { openCanvasGenerationDialog: ( dialog: Omit, ) => string; + activateCanvasGenerationDialog: ( + targetDialog: CanvasGenerationDialogState, + ) => void; updateCanvasGenerationDialogById: ( dialogId: string, updater: CanvasGenerationDialogUpdater, @@ -106,6 +109,13 @@ type ImageCanvasGenerationSurfaceOptions = { project: EditorProjectSnapshot, action?: CanvasHistoryAction, ) => void; + applyProjectSnapshotWithoutHistory?: ( + project: EditorProjectSnapshot, + ) => boolean | void; + flushProjectPersistence?: (options?: { + preferLatestGenerationDialogs?: boolean; + }) => Promise; + refreshAssetLibrary?: () => Promise | void; onWalletBalanceMayHaveChanged?: () => void; }; @@ -161,6 +171,7 @@ export function useImageCanvasGenerationSurface({ activeCanvasGenerationDialog, canvasGenerationDialogs, openCanvasGenerationDialog, + activateCanvasGenerationDialog, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, archiveActiveCanvasGenerationDialog, @@ -182,6 +193,9 @@ export function useImageCanvasGenerationSurface({ assetFolderId, upsertGeneratedAsset, applyProjectSnapshot, + applyProjectSnapshotWithoutHistory, + flushProjectPersistence, + refreshAssetLibrary, onWalletBalanceMayHaveChanged, }: ImageCanvasGenerationSurfaceOptions) { const toolbarOptionCloseTimerRef = useRef void generationWorkflow.submitCharacterAnimation() } + onRetryPerfectPixelOperation={(dialogId) => + void generationWorkflow.retryPerfectPixelOperation(dialogId) + } onUpdateSpecFormValue={generationWorkflow.updateSpecFormValue} onUpdateIconDescriptionText={ generationWorkflow.updateIconDescriptionsText diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index a73718193..e24b6ab90 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -7,9 +7,18 @@ import { screen, waitFor, } from '@testing-library/react'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiClientError } from '../../services/apiClient'; +import type { + EditorPixelArtSnapInput, + EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; +import { + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + splitCanvasLayoutItems, +} from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasLayer, @@ -19,8 +28,15 @@ import type { SidebarPanel, } from './ImageCanvasEditorTypes'; import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel'; +import { + readPerfectPixelOperations, + savePerfectPixelOperation, +} from './perfectPixelOperationStore'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; -import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; +import { + inspectPerfectPixelProjectSnapshot, + useImageCanvasGenerationWorkflow, +} from './useImageCanvasGenerationWorkflow'; const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); @@ -30,9 +46,12 @@ const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); +const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const renderCropExpandImageMock = vi.hoisted(() => vi.fn()); const removeImageBackgroundMock = vi.hoisted(() => vi.fn()); +const snapImageToPerfectPixelsMock = vi.hoisted(() => vi.fn()); +const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorImageReference', async () => { @@ -61,11 +80,13 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, generateEditorSoundEffect: generateEditorSoundEffectMock, + loadEditorProject: loadEditorProjectMock, splitEditorIconSpritesheet: splitEditorIconSpritesheetMock, }; }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ + uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock, uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); @@ -77,6 +98,7 @@ vi.mock('./ImageCanvasRasterEditModel', async () => { ...actual, removeImageBackground: removeImageBackgroundMock, renderCropExpandImage: renderCropExpandImageMock, + snapImageToPerfectPixels: snapImageToPerfectPixelsMock, }; }); @@ -113,20 +135,239 @@ function createGenerated(overrides = {}) { }; } +function createPerfectPixelResource(operationId: string) { + return { + resourceId: `resource-${operationId}`, + projectId: 'project-1', + imageSrc: `/generated-images/editor/${operationId}.png`, + objectKey: `generated-images/editor/${operationId}.png`, + assetObjectId: `asset-object-${operationId}`, + width: 1024, + height: 768, + sourceType: 'generated' as const, + taskId: `pixel-art-snap-${operationId}`, + }; +} + +function createPerfectPixelAsset(operationId: string) { + return { + assetId: `asset-${operationId}`, + folderId: 'default', + label: '源图 · 完美像素', + imageSrc: `/generated-images/editor/${operationId}.png`, + objectKey: `generated-images/editor/${operationId}.png`, + assetObjectId: `asset-object-${operationId}`, + width: 1024, + height: 768, + sourceType: 'generated' as const, + taskId: `pixel-art-snap-${operationId}`, + }; +} + +function createPerfectPixelProject( + operationId: string, + state: 'pending' | 'applied' | 'dialog-missing', +): EditorProjectSnapshot { + const resource = createPerfectPixelResource(operationId); + const generatedLayerId = `layer-${operationId}`; + const dialogLayer = { + itemType: 'generation-dialog', + layerId: `generation-dialog:${operationId}`, + resourceId: `generation-dialog:${operationId}`, + dialog: { + id: operationId, + mode: 'quick-edit', + prompt: '完美像素', + status: state === 'pending' ? 'generating' : 'idle', + composerOpen: false, + // 中文注释:服务端完成 completion 时只做字段级改写(status/composerOpen/ + // generatedLayerId/errorMessage),从不摘掉 perfectPixelOperationId。fixture 必须 + // 保留这个标记,否则「收口态占位 + 账本已清」这条真实形状永远测不到。 + perfectPixelOperationId: operationId, + ...(state === 'applied' ? { generatedLayerId } : {}), + }, + }; + return { + projectId: 'project-1', + title: '未命名画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: + state === 'dialog-missing' + ? [] + : [ + ...(state === 'applied' + ? [ + { + itemType: 'image', + layerId: generatedLayerId, + resourceId: resource.resourceId, + }, + ] + : []), + dialogLayer, + ], + resources: state === 'pending' ? [] : [resource], + updatedAt: '2026-08-03T00:00:00.000Z', + }; +} + +function createEmptyPerfectPixelProject(): EditorProjectSnapshot { + return { + projectId: 'project-1', + title: '未命名画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-08-03T00:00:00.000Z', + }; +} + +function createConflictingPerfectPixelProject( + operationId: string, +): EditorProjectSnapshot { + const project = createPerfectPixelProject(operationId, 'applied'); + return { + ...project, + resources: [ + ...project.resources, + { + ...createPerfectPixelResource(operationId), + resourceId: `resource-duplicate-${operationId}`, + }, + ], + }; +} + +function createPerfectPixelResult( + request: EditorPixelArtSnapInput, + project: EditorProjectSnapshot | null, +) { + const operationId = request.canvasCompletion.dialogId; + const resource = createPerfectPixelResource(operationId); + return { + imageSrc: resource.imageSrc, + objectKey: resource.objectKey, + assetObjectId: resource.assetObjectId, + width: resource.width, + height: resource.height, + sourceType: 'generated' as const, + taskId: resource.taskId, + elapsedMs: 250, + provider: 'Genarrative' as const, + resource, + asset: createPerfectPixelAsset(operationId), + project, + }; +} + +function createMismatchedPerfectPixelResult(request: EditorPixelArtSnapInput) { + const result = createPerfectPixelResult(request, null); + return { + ...result, + taskId: 'unexpected-task', + resource: { + ...result.resource, + taskId: 'unexpected-task', + }, + }; +} + +function createHydratedPerfectPixelDialog({ + operationId, + projectId = 'project-1', + status = 'generating', + reconcileUntil = Date.now() + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, +}: { + operationId: string; + projectId?: string; + status?: CanvasGenerationDialogState['status']; + reconcileUntil?: number; +}): CanvasGenerationDialogState { + const request: EditorPixelArtSnapInput = { + sourceImageSrc: 'generated-images/editor/source.png', + projectId, + sourceResourceId: 'resource-source', + assetLabel: '源图 · 完美像素', + canvasCompletion: { + dialogId: operationId, + title: '源图 · 完美像素', + placeholder: { + x: 60, + y: -132, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }; + return { + id: operationId, + mode: 'quick-edit', + prompt: '完美像素', + assetLabel: '源图 · 完美像素', + status, + composerOpen: false, + sourceLayerId: 'layer-source', + placeholder: { ...request.canvasCompletion.placeholder }, + perfectPixelOperation: { + version: 1, + kind: 'perfect-pixel', + operationId, + taskId: `pixel-art-snap-${operationId}`, + request, + submittedAt: Date.now() - 1_000, + reconcileUntil, + }, + }; +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + function GenerationWorkflowHarness({ initialLayers = [createLayer()], + initialCanvasGenerationDialogs, initialViewport = { x: 10, y: 20, scale: 2 }, projectId, currentUserId, applyProjectSnapshot, + applyProjectSnapshotWithoutHistory, + flushProjectPersistence, + onFlushProjectPersistenceSnapshot, + refreshAssetLibrary, + upsertGeneratedAsset, }: { initialLayers?: CanvasLayer[]; + initialCanvasGenerationDialogs?: CanvasGenerationDialogState[]; initialViewport?: { x: number; y: number; scale: number }; projectId?: string; currentUserId?: string; applyProjectSnapshot?: Parameters< typeof useImageCanvasGenerationWorkflow >[0]['applyProjectSnapshot']; + applyProjectSnapshotWithoutHistory?: Parameters< + typeof useImageCanvasGenerationWorkflow + >[0]['applyProjectSnapshotWithoutHistory']; + refreshAssetLibrary?: Parameters< + typeof useImageCanvasGenerationWorkflow + >[0]['refreshAssetLibrary']; + flushProjectPersistence?: Parameters< + typeof useImageCanvasGenerationWorkflow + >[0]['flushProjectPersistence']; + onFlushProjectPersistenceSnapshot?: ( + dialogs: CanvasGenerationDialogState[], + ) => void; + upsertGeneratedAsset?: Parameters< + typeof useImageCanvasGenerationWorkflow + >[0]['upsertGeneratedAsset']; }) { const [layers, setLayers] = useState(initialLayers); const [viewport, setViewport] = useState(initialViewport); @@ -144,6 +385,17 @@ function GenerationWorkflowHarness({ const fitLayersMockRef = useRef(vi.fn()); const layerCounterRef = useRef(0); const dialogs = useCanvasGenerationDialogs(); + const restoredInitialDialogsRef = useRef(false); + useEffect(() => { + if ( + restoredInitialDialogsRef.current || + !initialCanvasGenerationDialogs?.length + ) { + return; + } + restoredInitialDialogsRef.current = true; + dialogs.restoreCanvasGenerationDialogs(initialCanvasGenerationDialogs); + }, [dialogs, initialCanvasGenerationDialogs]); const activeDialogRef = useRef(null); const workflow = useImageCanvasGenerationWorkflow({ layers, @@ -156,6 +408,7 @@ function GenerationWorkflowHarness({ generateDialog: dialogs.generateDialog, setGenerateDialog: dialogs.setGenerateDialog, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, + activateCanvasGenerationDialog: dialogs.activateCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId: @@ -175,6 +428,17 @@ function GenerationWorkflowHarness({ projectId, currentUserId, applyProjectSnapshot, + applyProjectSnapshotWithoutHistory, + flushProjectPersistence: flushProjectPersistence + ? (options) => { + onFlushProjectPersistenceSnapshot?.( + dialogs.getCanvasGenerationDialogsSnapshot(), + ); + return flushProjectPersistence(options); + } + : undefined, + refreshAssetLibrary, + upsertGeneratedAsset, }); const activeDialog = dialogs.generateDialog; @@ -219,6 +483,9 @@ function GenerationWorkflowHarness({ : '-'} {activeDialog?.prompt || '-'} + + {activeDialog?.errorMessage || '-'} + {activeDialog ? `${activeDialog.mode}:${activeDialog.imageModel ?? '-'}:${activeDialog.aspectRatio ?? '-'}:${activeDialog.imageSize ?? '-'}` @@ -232,6 +499,20 @@ function GenerationWorkflowHarness({ ) .join('|') || '-'} + + {dialogs.canvasGenerationDialogs + .filter((dialog) => dialog.requiresLiveSession === true) + .map((dialog) => dialog.id) + .join('|') || '-'} + + + {activeDialog?.perfectPixelOperation + ? JSON.stringify(activeDialog.perfectPixelOperation) + : '-'} + + + {activeDialog?.perfectPixelOperationInvalid === true ? 'invalid' : '-'} + {activeDialog?.generationReferences ?.map((reference) => reference.label) @@ -292,6 +573,9 @@ function GenerationWorkflowHarness({ {fitLayersMockRef.current.mock.calls.length} {workflow.taskListRefreshKey} + + {workflow.perfectPixelLayerIds.has(layers[0]!.id) ? '处理中' : '空闲'} + @@ -529,6 +813,56 @@ function GenerationWorkflowHarness({ > 去除背景 + + + + + +
+ ); +} + describe('useImageCanvasProjectPersistence', () => { afterEach(() => { vi.useRealTimers(); @@ -822,6 +1002,310 @@ describe('useImageCanvasProjectPersistence', () => { }); }); + it('migrates a legacy inline ledger on a failed placeholder that exact retry still accepts', async () => { + // 中文注释:`failed + perfectPixelOperation` 是旧严格保存失败的合法持久化形状——请求 + // 已备好但 POST 从未发出,`retryPerfectPixelOperation` 明确接受该状态,面板上的 + // 「重试同一完美像素操作」也只在它带 operation 时才出现。按状态白名单列举会把这批漏掉: + // 下一次保存剥成 marker 后再加载就是 failed + invalid,重试按钮消失,用户只剩删掉重做 + // ——而那正是新 identity,正是 exact retry 要避免的重复。 + window.localStorage.clear(); + const dialogId = 'generation-dialog-legacy-prepared-failure'; + const submittedAt = Date.now(); + const legacyOperation = { + version: 1 as const, + kind: 'perfect-pixel' as const, + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'generated-images/editor/legacy-prepared.png', + projectId: 'editor-project-default', + assetLabel: '源图 · 完美像素', + canvasCompletion: { + dialogId, + title: '源图 · 完美像素', + placeholder: { ...STRICT_PLACEHOLDER }, + }, + }, + submittedAt, + reconcileUntil: submittedAt + 75_000, + }; + const legacyDialogItem = { + itemType: 'generation-dialog', + layerId: `generation-dialog:${dialogId}`, + resourceId: `generation-dialog:${dialogId}`, + dialog: { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + // 中文注释:失败但未收口——没有 generatedLayerId,账本仍是它唯一的重试凭据。 + status: 'failed', + composerOpen: false, + errorMessage: '完美像素请求尚未发出:提交前画布保存失败', + perfectPixelOperation: legacyOperation, + placeholder: { ...STRICT_PLACEHOLDER }, + }, + } as unknown as EditorProjectLayerSnapshot; + loadOrCreateRecentEditorProjectMock.mockResolvedValue({ + projectId: 'editor-project-default', + title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [legacyDialogItem], + revision: 4, + layoutStorageVersion: 0, + updatedAt: '2026-08-05T00:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [legacyDialogItem], + resources: [], + updatedAt: '2026-08-05T00:00:00.000Z', + }); + + render(); + await waitFor(() => { + expect(screen.getByTestId('strict-project-id').textContent).toBe( + 'editor-project-default', + ); + }); + + await waitFor(() => { + expect( + readPerfectPixelOperations('user-test', 'editor-project-default').size, + ).toBe(1); + }); + expect( + readPerfectPixelOperations('user-test', 'editor-project-default').get( + dialogId, + ), + ).toEqual(legacyOperation); + }); + + it('migrates a legacy inline perfect-pixel ledger into local storage on load', async () => { + // 中文注释:布局内联账本是账本移出布局之前的 legacy 形状。第一次 hydrate 还能认它,但 + // 下一次保存会把它剥成 perfectPixelOperationId 标记,此后再没有路径能补写本机账本—— + // 不迁移的话,部署那一刻仍在途的操作会在第二次加载变成 failed + invalid,永久失去 + // exact retry 的 identity。 + window.localStorage.clear(); + const dialogId = 'generation-dialog-legacy-inflight'; + const submittedAt = Date.now(); + const legacyOperation = { + version: 1 as const, + kind: 'perfect-pixel' as const, + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'generated-images/editor/legacy-source.png', + projectId: 'editor-project-default', + assetLabel: '源图 · 完美像素', + canvasCompletion: { + dialogId, + title: '源图 · 完美像素', + placeholder: { ...STRICT_PLACEHOLDER }, + }, + }, + submittedAt, + reconcileUntil: submittedAt + 75_000, + }; + const legacyDialogItem = { + itemType: 'generation-dialog', + layerId: `generation-dialog:${dialogId}`, + resourceId: `generation-dialog:${dialogId}`, + dialog: { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + composerOpen: false, + sourceLayerId: 'layer-source', + perfectPixelOperation: legacyOperation, + placeholder: { ...STRICT_PLACEHOLDER }, + }, + } as unknown as EditorProjectLayerSnapshot; + loadOrCreateRecentEditorProjectMock.mockResolvedValue({ + projectId: 'editor-project-default', + title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [legacyDialogItem], + revision: 3, + layoutStorageVersion: 0, + updatedAt: '2026-08-05T00:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [legacyDialogItem], + resources: [], + updatedAt: '2026-08-05T00:00:00.000Z', + }); + + render(); + await waitFor(() => { + expect(screen.getByTestId('strict-project-id').textContent).toBe( + 'editor-project-default', + ); + }); + + await waitFor(() => { + expect( + readPerfectPixelOperations('user-test', 'editor-project-default').size, + ).toBe(1); + }); + const migrated = readPerfectPixelOperations( + 'user-test', + 'editor-project-default', + ).get(dialogId); + expect(migrated).toEqual(legacyOperation); + + // 中文注释:往返验证——布局再保存一次只剩标记,此时只有本机账本能把它救回来。 + const strippedLayout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [ + { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + composerOpen: false, + perfectPixelOperationId: dialogId, + perfectPixelOperation: migrated, + } as unknown as CanvasGenerationDialogState, + ], + }); + expect(JSON.stringify(strippedLayout)).not.toContain( + '"perfectPixelOperation"', + ); + const { generationDialogs } = splitCanvasLayoutItems( + strippedLayout, + new Map(), + 'user-test', + readPerfectPixelOperations('user-test', 'editor-project-default'), + ); + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'pending-confirmation', + }); + expect(generationDialogs[0]?.perfectPixelOperation).toEqual( + legacyOperation, + ); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); + }); + + it('persists the perfect-pixel marker without leaking the request ledger into the layout', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('strict-project-id').textContent).toBe( + 'editor-project-default', + ); + }); + + act(() => { + screen + .getByRole('button', { name: 'open and flush perfect pixel' }) + .click(); + }); + + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalled(); + }); + const [, layoutInput] = saveEditorProjectLayoutMock.mock.calls[0] as [ + string, + { layers: unknown[] }, + ]; + const dialogItem = layoutInput.layers.find( + (item) => + (item as { layerId?: string }).layerId === + `generation-dialog:${STRICT_DIALOG_ID}`, + ); + // 中文注释:`preferLatestGenerationDialogs` 让刚创建的占位不会因为 ref 落后一帧而漏存。 + expect(dialogItem).toBeTruthy(); + expect( + (dialogItem as { dialog: Record }).dialog, + ).toMatchObject({ + id: STRICT_DIALOG_ID, + status: 'generating', + perfectPixelOperationId: STRICT_DIALOG_ID, + }); + // 中文注释:请求账本只在本机,布局里连源图地址都不该出现。 + expect(JSON.stringify(layoutInput.layers)).not.toContain( + '"perfectPixelOperation"', + ); + expect(JSON.stringify(layoutInput.layers)).not.toContain('resource-source'); + expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith( + 1, + 'editor-project-default', + expect.anything(), + ); + }); + + it.each([ + ['400', 400], + ['403', 403], + ])( + 'never blocks the caller when layout persistence fails with HTTP %s', + async (_label, status) => { + saveEditorProjectLayoutMock.mockRejectedValue( + new ApiClientError({ + message: '布局保存被拒绝', + status, + code: 'layout_rejected', + }), + ); + + render(); + await waitFor(() => { + expect(screen.getByTestId('strict-project-id').textContent).toBe( + 'editor-project-default', + ); + }); + + act(() => { + screen + .getByRole('button', { name: 'open and flush perfect pixel' }) + .click(); + }); + + // 中文注释:阶段 3 的核心断言——布局保存失败只是画布没同步上去,绝不能把下游 + // 完美像素 POST 拦下来。账本已经先落本机,请求仍然可追溯。 + await waitFor(() => { + expect(screen.getByTestId('strict-save-status').textContent).toBe( + 'resolved', + ); + }); + expect( + screen.getByTestId('strict-downstream-post-count').textContent, + ).toBe('1'); + }, + ); + + it('never blocks the caller when the project has no authority', async () => { + render( + , + ); + + act(() => { + screen + .getByRole('button', { name: 'open and flush perfect pixel' }) + .click(); + }); + + await waitFor(() => { + expect(screen.getByTestId('strict-save-status').textContent).toBe( + 'resolved', + ); + }); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( + '1', + ); + }); + it('saves appended layers with the server resource id immediately after resource creation', async () => { render(); @@ -2043,7 +2527,12 @@ describe('useImageCanvasProjectPersistence', () => { ); }); - it('flushes the latest layout and waits for the local cover cache before returning', async () => { + it('flushes the latest layout without waiting for the cover snapshot chain', async () => { + // 中文注释:flush 曾经 `await` 封面持久化,于是每个 await flush 的调用方(完美像素提交、 + // 人工重试、图集拆分)都被挂在封面链后面。封面渲染要为每个可绘制图层取 signed URL 再 + // `new Image()` 加载,而那个 Image 没有 timeout 也没有 AbortSignal——一张图不 settle, + // 生成 POST 就永远发不出去。await flush 的调用方要的只是「布局已持久化」这一个前置, + // 封面与它无关。本用例钉住:封面仍然被发起,但 flush 不等它。 const coverBlob = new Blob(['cover'], { type: 'image/webp' }); const coverCacheWrite = createDeferred(); createProjectCoverSnapshotBlobMock.mockResolvedValue(coverBlob); @@ -2083,19 +2572,52 @@ describe('useImageCanvasProjectPersistence', () => { blob: coverBlob, }); }); - expect(screen.getByTestId('flush-completed').textContent).toBe('false'); - coverCacheWrite.resolve(); + // 中文注释:封面缓存写入仍然挂着,flush 必须已经返回——这正是删除 `await coverSave` + // 要钉住的行为。 await waitFor(() => { expect(screen.getByTestId('flush-completed').textContent).toBe('true'); }); - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled(); + + // 中文注释:不等 ≠ 不做。封面链继续跑到底,上传与资源登记照常发生。 + coverCacheWrite.resolve(); + await waitFor(() => { + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled(); + }); expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ assetKind: 'project-cover-snapshot' }), ); }); + it('returns from flush even when the cover snapshot never settles', async () => { + // 中文注释:真实渲染器里的 `new Image()` 无 timeout / 无 AbortSignal,一张图既不 load + // 也不 error 时,封面链的 Promise 永不 settle。用永不 resolve 的 blob 模拟该形状—— + // 若 flush 仍 `await` 封面,本用例会挂到超时。 + createProjectCoverSnapshotBlobMock.mockReturnValue(new Promise(() => {})); + + render(); + + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + act(() => { + screen.getByRole('button', { name: 'append server resource' }).click(); + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + await waitFor(() => { + expect(screen.getByTestId('viewport').textContent).toBe('10,5,0.5'); + }); + + act(() => { + screen.getByRole('button', { name: 'flush project persistence' }).click(); + }); + + await waitFor(() => { + expect(screen.getByTestId('flush-completed').textContent).toBe('true'); + }); + expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalled(); + expect(putEditorProjectCoverCacheMock).not.toHaveBeenCalled(); + }); + it('loads and saves viewport scale with display zoom semantics', async () => { render(); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 9d94a4fb0..f4e1509c8 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -23,8 +23,10 @@ import { canvasDisplayViewportToViewport, type CanvasLayerResourceMetadata, DEFAULT_CANVAS_BACKGROUND_COLOR, + dropDeadInlineGenerationPlaceholders, hydrateLayer, isInlineEditorMediaSource, + isUnresolvedCanvasGenerationDialogRecord, resolveLayerResourceAssetKind, serializeCanvasLayout, splitCanvasLayoutItems, @@ -48,6 +50,10 @@ import { firstSelectedLayerId, normalizeCanvasSelectionIds, } from './ImageCanvasSelectionModel'; +import { + readPerfectPixelOperations, + savePerfectPixelOperation, +} from './perfectPixelOperationStore'; type ProjectResourceOptions = { onCreated?: (resourceId: string) => void; @@ -201,6 +207,7 @@ type ImageCanvasProjectPersistenceRefs = { layersRef: RefObject; viewportRef: RefObject; canvasGenerationDialogsRef: RefObject; + getCanvasGenerationDialogsSnapshot?: () => CanvasGenerationDialogState[]; canvasBackgroundColorRef: RefObject; selectedLayerIdRef: RefObject; selectedLayerIdsRef: RefObject; @@ -551,6 +558,10 @@ export function useImageCanvasProjectPersistence({ const coverSnapshotViewportSizeRef = useRef(canvasSize); const [projectId, setProjectId] = useState(null); const [isProjectReady, setIsProjectReady] = useState(false); + // 中文注释:累计而不是布尔——同一次会话里可能连着切换多个项目,每个都可能留有孤儿占位, + // 布尔只会提示一次。调用方以计数变化为触发条件。 + const [deadInlinePlaceholderDropCount, setDeadInlinePlaceholderDropCount] = + useState(0); const { setProjectTitle, setProjectRenameValue, @@ -613,18 +624,25 @@ export function useImageCanvasProjectPersistence({ hasAuthoritativeProjectSnapshotRef.current && authoritativeProjectIdRef.current === pendingSave.projectId; let runNextSave = false; - const savePromise = saveEditorProjectLayout(pendingSave.projectId, { + const saveInput = { ...pendingSave.input, expectedRevision, - }) + }; + const savePromise = saveEditorProjectLayout( + pendingSave.projectId, + saveInput, + ) .then((result) => { + const acknowledgedRevision = + result && typeof result.revision === 'number' + ? result.revision + : null; if ( saveStillBelongsToCurrentAuthority() && - result && - typeof result.revision === 'number' && - result.revision > (projectRevisionRef.current ?? -1) + acknowledgedRevision !== null && + acknowledgedRevision > (projectRevisionRef.current ?? -1) ) { - projectRevisionRef.current = result.revision; + projectRevisionRef.current = acknowledgedRevision; authoritativeLayoutItemIdsRef.current = new Set( attemptedSave.input.layers.flatMap((item) => { const id = canvasLayoutItemId(item); @@ -648,10 +666,12 @@ export function useImageCanvasProjectPersistence({ latestProject: EditorProjectSnapshot, ) => { if (!saveStillBelongsToCurrentAuthority()) { - return; + return false; } - applyProjectSnapshotRef.current?.(latestProject); + const applied = + applyProjectSnapshotRef.current?.(latestProject) === true; runNextSave = Boolean(pendingProjectLayoutSaveRef.current); + return applied; }; const scheduleAuthoritativeReload = (retryCount: number) => { if ( @@ -697,11 +717,14 @@ export function useImageCanvasProjectPersistence({ } return; } - if (pendingProjectLayoutSaveRef.current) { - runNextSave = true; + if (!isRetryableEditorProjectLayoutSaveError(error)) { + if (pendingProjectLayoutSaveRef.current) { + runNextSave = true; + } return; } - if (!isRetryableEditorProjectLayoutSaveError(error)) { + if (pendingProjectLayoutSaveRef.current) { + runNextSave = true; return; } const transportRetries = pendingSave.transportRetries ?? 0; @@ -868,7 +891,10 @@ export function useImageCanvasProjectPersistence({ Parameters[1], 'expectedRevision' >, - options: { delayMs?: number; persistCover?: boolean } = {}, + options: { + delayMs?: number; + persistCover?: boolean; + } = {}, ) => { const revision = projectRevisionRef.current; if ( @@ -876,7 +902,7 @@ export function useImageCanvasProjectPersistence({ authoritativeProjectIdRef.current !== nextProjectId || revision === null ) { - return; + return false; } pendingProjectLayoutSaveRef.current = { projectId: nextProjectId, @@ -915,7 +941,7 @@ export function useImageCanvasProjectPersistence({ } runPendingProjectLayoutSave(); }, delayMs); - return; + return true; } if (shouldPersistCover) { @@ -926,6 +952,7 @@ export function useImageCanvasProjectPersistence({ ); } runPendingProjectLayoutSave(); + return true; }, [ currentUserId, @@ -935,68 +962,82 @@ export function useImageCanvasProjectPersistence({ ], ); - const flushProjectPersistence = useCallback(async () => { - while (activeProjectLayoutSavePromiseRef.current) { - await activeProjectLayoutSavePromiseRef.current; - } + /** + * 中文注释:布局保存只有「尽力而为」这一种语义。 + * + * 曾经存在一条 strict 变体:完美像素在发 POST 前必须拿到布局保存的 revision ack,因为 + * 请求账本当时写在布局里。账本移到本机之后(见 perfectPixelOperationStore)那个前置 + * 条件不再成立,strict 通道连同它引入的阻断一起删除——布局保存失败此后只是「这次画布 + * 状态没同步上去」,不再能拦住任何生成操作。 + * + * `preferLatestGenerationDialogs` 只影响取哪一份占位快照:置位时取本轮 render 之前已 + * 提交的最新占位,避免刚创建的占位因为 ref 落后一帧而漏存。它不改变失败语义。 + */ + const flushProjectPersistence = useCallback( + async ( + options: { + preferLatestGenerationDialogs?: boolean; + } = {}, + ) => { + while (activeProjectLayoutSavePromiseRef.current) { + await activeProjectLayoutSavePromiseRef.current; + } - const nextProjectId = projectIdRef.current; - if ( - !nextProjectId || - !canAccessProtectedDataRef.current || - !hasAuthoritativeProjectSnapshotRef.current || - authoritativeProjectIdRef.current !== nextProjectId || - projectRevisionRef.current === null - ) { - return; - } + const nextProjectId = projectIdRef.current; + if ( + !nextProjectId || + !canAccessProtectedDataRef.current || + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== nextProjectId || + projectRevisionRef.current === null + ) { + return; + } - const coverDisplayViewport = viewportToCanvasDisplayViewport( - refs.viewportRef.current, - ); - queueProjectLayoutSave( - nextProjectId, - { + const coverDisplayViewport = viewportToCanvasDisplayViewport( + refs.viewportRef.current, + ); + const generationDialogs = + options.preferLatestGenerationDialogs && + refs.getCanvasGenerationDialogsSnapshot + ? refs.getCanvasGenerationDialogsSnapshot() + : refs.canvasGenerationDialogsRef.current; + const layoutInput = { viewport: coverDisplayViewport, layers: serializeCanvasLayout({ layers: refs.layersRef.current, - canvasGenerationDialogs: refs.canvasGenerationDialogsRef.current, + canvasGenerationDialogs: generationDialogs, canvasBackgroundColor: refs.canvasBackgroundColorRef.current, }), - }, - { persistCover: false }, - ); - - const coverSave = persistProjectCoverSnapshot( - nextProjectId, - coverDisplayViewport, - refs.layersRef.current, - ); - while ( - activeProjectLayoutSavePromiseRef.current || - pendingProjectLayoutSaveRef.current - ) { - const activeLayoutSave = activeProjectLayoutSavePromiseRef.current; - if (activeLayoutSave) { - await activeLayoutSave; - continue; + }; + // 中文注释:封面走 `queueProjectLayoutSave` 内建的 fire-and-forget 分支,flush **不等** + // 它。此前 flush 显式关掉那条分支、自己起一份并在最后 `await`,于是任何 await flush 的 + // 调用方都被挂在封面链后面——而封面渲染要为每个可绘制图层取 signed URL 再 `new Image()` + // 加载,那个 Image 没有 timeout 也没有 AbortSignal,一张图不 settle 就永远不 settle。 + // 完美像素与图集拆分 await flush 只为满足一个服务端前置:占位/图层布局已经持久化。那个 + // 前置在下面的队列排干时就已满足,封面与它无关,不该挡在 POST 前面。 + queueProjectLayoutSave(nextProjectId, layoutInput); + while ( + activeProjectLayoutSavePromiseRef.current || + pendingProjectLayoutSaveRef.current + ) { + const activeLayoutSave = activeProjectLayoutSavePromiseRef.current; + if (activeLayoutSave) { + await activeLayoutSave; + continue; + } + if (saveTimerRef.current !== null) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + runPendingProjectLayoutSave(); + if (!activeProjectLayoutSavePromiseRef.current) { + break; + } } - if (saveTimerRef.current !== null) { - window.clearTimeout(saveTimerRef.current); - saveTimerRef.current = null; - } - runPendingProjectLayoutSave(); - if (!activeProjectLayoutSavePromiseRef.current) { - break; - } - } - await coverSave; - }, [ - persistProjectCoverSnapshot, - queueProjectLayoutSave, - refs, - runPendingProjectLayoutSave, - ]); + }, + [queueProjectLayoutSave, refs, runPendingProjectLayoutSave], + ); const applyCreatedProjectResourceLayer = useCallback( (pendingLayer: PendingCreatedProjectResourceLayer) => { @@ -1196,15 +1237,17 @@ export function useImageCanvasProjectPersistence({ } const pendingSave = pendingProjectLayoutSaveRef.current; const activeSaveAttempt = activeProjectLayoutSaveAttemptRef.current; + const activeLocalSave = + authoritative && + activeSaveAttempt?.save.projectId === project.projectId && + activeSaveAttempt.authorityEpoch === projectAuthorityEpochRef.current && + activeSaveAttempt.ownerUserId === currentUserIdRef.current + ? activeSaveAttempt.save + : null; const pendingLocalLayout = authoritative ? pendingSave?.projectId === project.projectId ? pendingSave - : activeSaveAttempt?.save.projectId === project.projectId && - activeSaveAttempt.authorityEpoch === - projectAuthorityEpochRef.current && - activeSaveAttempt.ownerUserId === currentUserIdRef.current - ? activeSaveAttempt.save - : null + : activeLocalSave : null; const previousAuthoritativeItemIds = authoritativeLayoutItemIdsRef.current; @@ -1283,12 +1326,45 @@ export function useImageCanvasProjectPersistence({ }, ]), ); + // 中文注释:请求账本只在本机。读不到(换设备、清缓存、隐私模式)时占位会被 + // hydrate 收口成可删除的失败态——这是明确设计,不阻断任何后续操作。 + const localPerfectPixelOperations = readPerfectPixelOperations( + currentUserId, + project.projectId, + ); const { layerItems, generationDialogs, canvasBackgroundColor } = splitCanvasLayoutItems( appliedLayoutItems, resourcesById, currentUserId, + localPerfectPixelOperations, ); + // 中文注释:legacy 内联账本一次性迁到本机。布局里的内联快照会在下一次保存时被剥成 + // `perfectPixelOperationId` 标记,此后再没有任何路径能把它补写进本机账本——不迁移 + // 的话,部署那一刻仍在途的操作会在第二次加载变成 `failed + invalid`,永久失去 exact + // retry 的 identity。 + // + // 判据用「未收口」而不是列举状态:`failed + perfectPixelOperation` 是旧严格保存失败 + // 的合法持久化形状,`retryPerfectPixelOperation` 也明确接受 `failed`,按状态白名单 + // 列举会把这批仍能 exact retry 的占位漏掉。收口态(带 generatedLayerId)本就不需要 + // 账本,与 `hydrateCanvasGenerationDialog` 同用一个判据,两处不会漂移。 + // 只补写本机缺失的:本机那份可能刚在 pre-POST flush 之后被重新锚定过,比布局里的新。 + for (const dialog of generationDialogs) { + const operation = dialog.perfectPixelOperation; + if ( + operation && + !localPerfectPixelOperations.has(dialog.id) && + isUnresolvedCanvasGenerationDialogRecord( + dialog as unknown as Record, + ) + ) { + savePerfectPixelOperation( + currentUserId, + project.projectId, + operation, + ); + } + } const hydratedLayers = layerItems .map((layer) => hydrateLayer(layer, resourcesById)) .filter((layer): layer is CanvasLayer => Boolean(layer)); @@ -1417,18 +1493,38 @@ export function useImageCanvasProjectPersistence({ projectIdFromQuery, currentUserId, ); + // 中文注释:缓存里剥掉的数量单独记,不能直接并入提示计数。会话缓存可能停留在完美像素 + // 完成之前的版本(`applyProjectSnapshot` 置了 skipNext,成功后的第一次 effect 不落库), + // 此时缓存里的占位是陈旧的而权威快照其实已经成功——并入就会报一条「上次处理未完成」的 + // 假告警。只有权威加载没能纠正这幅画面时,这个计数才代表真的有孤儿占位被静默清掉。 + let cachedDeadPlaceholderCount = 0; if (cachedProject) { - applyProjectSnapshot(cachedProject.project, { authoritative: false }); + // 中文注释:会话缓存写于占位落库之后,同样可能带着已死会话的 generating 占位, + // 必须和权威快照走同一道剥离,否则首屏会先闪一个永远转圈的占位。 + const cached = dropDeadInlineGenerationPlaceholders( + cachedProject.project, + ); + cachedDeadPlaceholderCount = cached.droppedCount; + applyProjectSnapshot(cached.project, { authoritative: false }); } const loadProject = projectIdFromQuery ? loadEditorProject(projectIdFromQuery) : loadOrCreateRecentEditorProject(); loadProject - .then((project) => { + .then((loadedProject) => { if (cancelled) { return; } + // 中文注释:只在这里剥离。会话内 applyQueuedEditorGenerationProject 也会重新 GET + // 项目并套用,那时候占位对应的操作正在进行,套用剥离会把自己的活占位清掉。 + const { project, droppedCount } = + dropDeadInlineGenerationPlaceholders(loadedProject); + if (droppedCount > 0) { + setDeadInlinePlaceholderDropCount( + (currentCount) => currentCount + droppedCount, + ); + } const projectIsAuthoritative = applyProjectSnapshot(project, { allowProjectSwitch: true, }); @@ -1468,6 +1564,16 @@ export function useImageCanvasProjectPersistence({ return; } replaceAppHistoryPath('/project'); + return; + } + // 中文注释:权威快照没能到达,缓存里剥掉的占位就没有第二个来源可以纠正。此时画布 + // 仍在渲染(isProjectReady 只管启动意图与自动保存,不挡画面),用户看到的是一张 + // 静默少了占位的画布——必须提示,否则他既不知道占位被清掉,也不知道素材库可能已有 + // 派生图。鉴权失败与项目失访这两条路径已各自跳转或弹窗,不在这里重复打扰。 + if (cachedDeadPlaceholderCount > 0) { + setDeadInlinePlaceholderDropCount( + (currentCount) => currentCount + cachedDeadPlaceholderCount, + ); } }); @@ -1552,5 +1658,6 @@ export function useImageCanvasProjectPersistence({ appendCanvasLayersWithResources, applyProjectSnapshot, flushProjectPersistence, + deadInlinePlaceholderDropCount, }; } diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx new file mode 100644 index 000000000..638a0c19c --- /dev/null +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx @@ -0,0 +1,401 @@ +/* @vitest-environment jsdom */ + +import { act, render, renderHook } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + CanvasGenerationDialogState, + PerfectPixelOperationSnapshot, +} from './ImageCanvasEditorTypes'; +import { + useInlineGenerationPlaceholderExpiry, + useInlineGenerationPlaceholderOwnership, +} from './useInlineGenerationPlaceholderExpiry'; + +const WINDOW_MS = 240_000; + +function perfectPixelOperation( + dialogId: string, +): PerfectPixelOperationSnapshot { + return { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'ref:project-resource:resource-source', + projectId: 'project-1', + sourceResourceId: 'resource-source', + assetKind: 'character', + assetLabel: '角色 · 完美像素', + canvasCompletion: { + dialogId, + title: '角色 · 完美像素', + placeholder: { + x: 100, + y: 120, + width: 320, + height: 320, + originalWidth: 640, + originalHeight: 640, + }, + }, + }, + submittedAt: 1_700_000_000_000, + reconcileUntil: 1_700_000_120_000, + }; +} + +function dialog( + overrides: Partial, +): CanvasGenerationDialogState { + return { + id: 'dialog-1', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + ...overrides, + } as CanvasGenerationDialogState; +} + +function Harness({ + dialogs, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, +}: { + dialogs: CanvasGenerationDialogState[]; + removeCanvasGenerationDialogById: (dialogId: string) => void; + onPlaceholdersExpired: (expiredCount: number) => void; +}) { + const activeInlineGenerationDialogOwnership = + useInlineGenerationPlaceholderOwnership(); + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs: dialogs, + activeInlineGenerationDialogOwnership, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, + }); + return null; +} + +describe('useInlineGenerationPlaceholderExpiry', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-03T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it.each(['generating', 'pending-confirmation'] as const)( + 'never expires a durable perfect-pixel placeholder in %s state', + (status) => { + const dialogId = `dialog-perfect-pixel-${status}`; + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + + render( + , + ); + + // 中文注释:请求快照是 durable operation 凭证,只能通过 GET 对账收口。即使远超 + // legacy 240 秒窗口,也不能挂旧占位删除定时器,更不能触发删除后的布局持久化。 + expect(vi.getTimerCount()).toBe(0); + + act(() => { + vi.advanceTimersByTime(WINDOW_MS * 100); + }); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + expect(onPlaceholdersExpired).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it('still expires a legacy inline placeholder without an operation snapshot', () => { + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + + render( + , + ); + + expect(vi.getTimerCount()).toBe(1); + + act(() => { + vi.advanceTimersByTime(WINDOW_MS * 100); + }); + + expect(removeCanvasGenerationDialogById).toHaveBeenCalledOnce(); + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + expect(onPlaceholdersExpired).toHaveBeenCalledOnce(); + expect(onPlaceholdersExpired).toHaveBeenCalledWith(1); + }); + + it('clears an inline placeholder once its live window elapses', () => { + // 中文注释:加载期剥离只跑一次,当时未到期而被保留的孤儿占位靠这里补收口,否则会一直 + // 转到用户下一次加载——那正是引入存活窗口带来的回归。 + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + render( + , + ); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + + // 中文注释:定时器触发的是 setState,必须包 act 才会同步刷新出重新判定那一轮。 + act(() => { + vi.advanceTimersByTime(2_000); + }); + + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + expect(onPlaceholdersExpired).toHaveBeenCalledWith(1); + }); + + it('re-evaluates on wake instead of acting on the armed snapshot', () => { + // 中文注释:挂上定时器之后占位可能已被拥有者会话正常收口。到期回调若按闭包里的旧值行动, + // 就会清掉一个已经完成的占位——判定必须在醒来那一刻用当前 dialogs 重做。 + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + const startedAt = Date.now() - WINDOW_MS + 1_000; + const { rerender } = render( + , + ); + + rerender( + , + ); + + act(() => { + vi.advanceTimersByTime(10_000); + }); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + expect(onPlaceholdersExpired).not.toHaveBeenCalled(); + }); + + it('still expires while unrelated dialogs keep churning', () => { + // 中文注释:dialogs 变动很频繁(提交工作流三十余处变更点,队列型生成轮询期间持续更新), + // 而这个定时器要熬三分钟。曾怀疑无关变动会不断重挂定时器、让它永远等不到触发;本用例 + // 证明不会:数组身份变化会让 effect 重跑,而 effect 体每次都重新判定到期,频繁变动带来 + // 的是更频繁的判定,不比定时器差;不变动时数组稳定,定时器正常存活。两条路径都收口。 + // 保留这条用例是为了钉住这个性质——将来若把判定挪出 effect 体,就会真的失效。 + const removeCanvasGenerationDialogById = vi.fn(); + const startedAt = Date.now() - WINDOW_MS + 30_000; + const inlineDialog = dialog({ + requiresLiveSession: true, + generationStartedAt: startedAt, + }); + const renderWith = (queuedProgress: number) => ( + + ); + const { rerender } = render(renderWith(0)); + + // 中文注释:每秒一次无关变动,持续到该占位到期之后。 + for (let tick = 1; tick <= 40; tick += 1) { + act(() => { + vi.advanceTimersByTime(1_000); + }); + rerender(renderWith(tick)); + } + + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + }); + + it('never expires a placeholder the current session still owns', () => { + // 中文注释:占位创建后还要走源图解析/直传和 flush 才轮到受超时保护的 POST,这段慢起来 + // 会越过存活窗口。此时占位仍是 requiresLiveSession + generating,与已死会话留下的孤儿 + // 在状态上完全一致——只能靠显式登记归属区分。删掉自己正在用的占位会让随后的 POST 因 + // 占位不存在返回 409。 + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + const dialogs = [ + dialog({ + requiresLiveSession: true, + generationStartedAt: Date.now() - WINDOW_MS + 1_000, + }), + ]; + const { result } = renderHook( + () => { + const ownership = useInlineGenerationPlaceholderOwnership(); + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs: dialogs, + activeInlineGenerationDialogOwnership: ownership, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, + }); + return ownership; + }, + { wrapper: StrictMode }, + ); + + expect(vi.getTimerCount()).toBe(1); + act(() => { + expect(result.current.claim('dialog-1')).toBe(true); + }); + expect(result.current.version).toBe(1); + + // 中文注释:claim 只改变 ownership 的私有 Set 与 version;dialogs 和两个 callback + // identity 始终不变。归属过滤必须同时取消下一到期 timer,不能在超窗后进入 50ms 忙等。 + expect(vi.getTimerCount()).toBe(0); + + act(() => { + vi.advanceTimersByTime(WINDOW_MS * 2); + }); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + expect(onPlaceholdersExpired).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('expires the placeholder once the session releases ownership', () => { + // 中文注释:归属在 finally 里释放。此用例不 rerender,也不更换 dialogs / callbacks; + // 唯一唤醒 effect 的必须是生产 ownership hook 在 release 时推进的 version。 + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + const dialogs = [ + dialog({ + requiresLiveSession: true, + generationStartedAt: Date.now() - WINDOW_MS + 1_000, + }), + ]; + const { result } = renderHook( + () => { + const ownership = useInlineGenerationPlaceholderOwnership(); + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs: dialogs, + activeInlineGenerationDialogOwnership: ownership, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, + }); + return ownership; + }, + { wrapper: StrictMode }, + ); + + act(() => { + expect(result.current.claim('dialog-1')).toBe(true); + expect(result.current.claim('dialog-1')).toBe(false); + }); + expect(result.current.version).toBe(1); + act(() => { + vi.advanceTimersByTime(2_000); + }); + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + + act(() => { + expect(result.current.release('dialog-1')).toBe(true); + }); + expect(result.current.version).toBe(2); + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + expect(removeCanvasGenerationDialogById).toHaveBeenCalledOnce(); + expect(onPlaceholdersExpired).toHaveBeenCalledWith(1); + expect(onPlaceholdersExpired).toHaveBeenCalledOnce(); + + act(() => { + expect(result.current.release('dialog-1')).toBe(false); + }); + expect(result.current.version).toBe(2); + expect(removeCanvasGenerationDialogById).toHaveBeenCalledOnce(); + expect(onPlaceholdersExpired).toHaveBeenCalledOnce(); + }); + + it('never arms a timer for queue-backed placeholders', () => { + // 中文注释:队列型占位的 job 在服务端继续跑,worker 会替换占位。自动清理会让用户以为 + // 操作没发生而重复提交。 + const removeCanvasGenerationDialogById = vi.fn(); + render( + , + ); + + act(() => { + vi.advanceTimersByTime(WINDOW_MS * 20); + }); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + }); + + it('clears an already-expired placeholder without waiting for a timer', () => { + // 中文注释:加载时就已超窗的情形由快照侧剥离处理,但内存态也可能直接拿到超窗占位 + // (例如会话内切换项目),此时不该再等一轮定时器。 + const removeCanvasGenerationDialogById = vi.fn(); + render( + , + ); + + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + }); +}); diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts new file mode 100644 index 000000000..7e1f07921 --- /dev/null +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + collectExpiredInlineGenerationDialogIds, + resolveNextInlineGenerationDialogExpiryAt, +} from './ImageCanvasEditorModel'; +import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; + +export type InlineGenerationPlaceholderOwnership = Readonly<{ + version: number; + claim: (dialogId: string) => boolean; + release: (dialogId: string) => boolean; + has: (dialogId: string) => boolean; +}>; + +/** + * 中文注释:同步 Set 是本会话 ownership 的即时真值;version 只负责把 membership 变化 + * 通知给 React。所有写入都必须经过 claim / release,避免直接修改 ref 后 effect 无法观察。 + */ +export function useInlineGenerationPlaceholderOwnership(): InlineGenerationPlaceholderOwnership { + const activeDialogIdsRef = useRef(new Set()); + const [version, setVersion] = useState(0); + const claim = useCallback((dialogId: string) => { + if (activeDialogIdsRef.current.has(dialogId)) { + return false; + } + activeDialogIdsRef.current.add(dialogId); + setVersion((currentVersion) => currentVersion + 1); + return true; + }, []); + const release = useCallback((dialogId: string) => { + if (!activeDialogIdsRef.current.delete(dialogId)) { + return false; + } + setVersion((currentVersion) => currentVersion + 1); + return true; + }, []); + const has = useCallback( + (dialogId: string) => activeDialogIdsRef.current.has(dialogId), + [], + ); + + return useMemo( + () => ({ + version, + claim, + release, + has, + }), + [claim, has, release, version], + ); +} + +type InlineGenerationPlaceholderExpiryOptions = { + canvasGenerationDialogs: CanvasGenerationDialogState[]; + activeInlineGenerationDialogOwnership: InlineGenerationPlaceholderOwnership; + removeCanvasGenerationDialogById: (dialogId: string) => void; + onPlaceholdersExpired: (expiredCount: number) => void; +}; + +/** + * 中文注释:页面打开期间的 inline 占位到期清理。 + * + * 加载期的 `dropDeadInlineGenerationPlaceholders` 只在项目首次加载执行一次,所以当时还没 + * 越过存活窗口、被保留下来的孤儿占位再没有任何东西会重新判定——它会一直转到用户下一次 + * 加载为止。本 hook 补上这一段:算出下一个到期时刻挂一次性定时器,到点按同一条规则处置。 + * + * 三个必须守住的点: + * + * 一、到期回调**不做判定**,只推进 tick 让 effect 重跑,判定始终在 effect 体里用当前的 + * dialogs 和当前时间做。挂上定时器之后占位可能已被拥有者会话正常收口、或被用户删掉, + * 按闭包里的旧值行动会清掉一个已经完成的占位。 + * + * 二、清理用底层的 `removeCanvasGenerationDialogById`,不是 View 的 + * `removeCanvasGenerationDialog`。后者是用户主动删除的语义:写一条 + * `delete-generation-result` 历史、清空选中、切回选择工具。自动清理记用户没做过的历史、 + * 抢走用户当前的选中态和工具,都是错的;加载期剥离同样不做这些。 + * + * 三、本会话自己在途的占位不会被误清。ownership 的同步 Set 负责在首个 await 前立即 + * 挡住清理;claim / release 同时推进可观察 version,让 membership 变化必然触发重新判定。 + * 不能把可变 ref 对象本身放进依赖后直接修改 `.current`,React 不会观察这种变化。 + */ +export function useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs, + activeInlineGenerationDialogOwnership, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, +}: InlineGenerationPlaceholderExpiryOptions) { + const [expiryTick, setExpiryTick] = useState(0); + const { + has: isDialogOwnedByCurrentSession, + version: activeInlineGenerationOwnershipVersion, + } = activeInlineGenerationDialogOwnership; + useEffect(() => { + // 中文注释:本会话仍在执行的占位一律跳过。到期清理只针对已死会话留下的孤儿,而 + // dialog 状态区分不出这两者——本会话在源图直传或布局保存阶段慢起来时,它的占位同样 + // 是 requiresLiveSession + generating,按状态判定会把自己正在用的占位删掉,随后 POST + // 因占位不存在返回 409。 + // + // 这一条不能靠「到期重新判定」兜住:重新判定只能识别「已经收口的占位」,识别不出 + // 「仍在合法运行的占位」——后者正处于要被删除的那个状态。归属必须显式登记。 + // 中文注释:归属过滤必须先于两处判定同时生效。只过滤 expiredIds 会留下一个死循环: + // 被本会话持有的超窗占位不进 expiredIds,却仍被 resolveNext... 算出一个**已经过去**的 + // 到期时刻,delayMs 塌成 50ms,定时器触发 → tick → 重跑 → 状态没变 → 再挂 50ms, + // 变成每 50 毫秒一次 setState 的忙等。可达路径是真实的:前置 90 秒 + POST 120 秒之后 + // catch 里还要做对账 GET,而归属要到 finally 才释放。 + const unownedDialogs = canvasGenerationDialogs.filter( + (dialog) => !isDialogOwnedByCurrentSession(dialog.id), + ); + const expiredIds = collectExpiredInlineGenerationDialogIds(unownedDialogs); + if (expiredIds.length > 0) { + for (const dialogId of expiredIds) { + removeCanvasGenerationDialogById(dialogId); + } + onPlaceholdersExpired(expiredIds.length); + return undefined; + } + const nextExpiryAt = + resolveNextInlineGenerationDialogExpiryAt(unownedDialogs); + if (nextExpiryAt === null) { + return undefined; + } + // 中文注释:多给 50ms 余量。定时器只保证「不早于」,贴着到期时刻醒来会让 effect 重跑一次 + // 却判定为未到期,白白多挂一轮。 + const delayMs = Math.max(0, nextExpiryAt - Date.now()) + 50; + const timer = window.setTimeout(() => { + setExpiryTick((currentTick) => currentTick + 1); + }, delayMs); + return () => { + window.clearTimeout(timer); + }; + }, [ + activeInlineGenerationOwnershipVersion, + canvasGenerationDialogs, + expiryTick, + isDialogOwnedByCurrentSession, + onPlaceholdersExpired, + removeCanvasGenerationDialogById, + ]); +} diff --git a/src/index.css b/src/index.css index d8db436f8..f8e1a361f 100644 --- a/src/index.css +++ b/src/index.css @@ -5558,6 +5558,14 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { box-shadow: 0 18px 32px var(--image-canvas-brand-shadow); } +.image-canvas-editor__generation-frame--pending-confirmation { + border-color: var(--image-canvas-brand-accent); + cursor: pointer; + box-shadow: + 0 18px 42px rgba(15, 23, 42, 0.1), + inset 0 0 0 1px rgba(255, 255, 255, 0.72); +} + .image-canvas-editor__generation-frame-progress { position: absolute; left: 50%; @@ -5586,6 +5594,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { animation: image-canvas-generation-pulse 1s ease-in-out infinite; } +.image-canvas-editor__generation-frame-progress--pending-confirmation::before { + animation: none; +} + @keyframes image-canvas-generation-scan { from { transform: translateX(-100%); diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index d411171bf..8bd1ba54a 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -55,6 +55,16 @@ function createResponseMock(params: { }; } +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + describe('apiClient', () => { const fetchMock = vi.fn(); const dispatchEventMock = vi.fn(); @@ -687,6 +697,83 @@ describe('apiClient', () => { expect(capturedError).toBeInstanceOf(Error); }); + it.each(['missing-token', 'unauthorized'] as const)( + 'bounds the %s refresh wait with an absolute request deadline', + async (refreshMode) => { + const refreshResponse = createDeferred< + ReturnType + >(); + if (refreshMode === 'unauthorized') { + setStoredAccessToken('expired-token', { emit: false }); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockImplementationOnce(() => refreshResponse.promise); + } else { + fetchMock.mockImplementationOnce(() => refreshResponse.promise); + } + + const request = requestJson( + '/api/runtime/protected', + { method: 'GET' }, + '读取受保护数据失败', + { deadlineAt: Date.now() + 20 }, + ); + + await expect(request).rejects.toMatchObject({ name: 'TimeoutError' }); + expect(fetchMock).toHaveBeenCalledTimes( + refreshMode === 'unauthorized' ? 2 : 1, + ); + expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('/api/auth/refresh'); + if (refreshMode === 'unauthorized') { + expect(getStoredAccessToken()).toBe('expired-token'); + } else { + expect(getStoredAccessToken()).toBe(''); + } + expect(dispatchEventMock).not.toHaveBeenCalled(); + + refreshResponse.resolve( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { token: 'late-refresh-token' }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + }), + ); + await vi.waitFor(() => { + expect(getStoredAccessToken()).toBe('late-refresh-token'); + }); + expect(dispatchEventMock).not.toHaveBeenCalled(); + }, + ); + + it.each([200, 400])( + 'bounds a %i response body read with the same absolute request deadline', + async (status) => { + setStoredAccessToken('body-timeout-token', { emit: false }); + const responseBody = createDeferred(); + const response = createResponseMock({ status }); + response.text.mockImplementationOnce(() => responseBody.promise); + fetchMock.mockResolvedValueOnce(response); + + const request = requestJson( + '/api/runtime/protected', + { method: 'GET' }, + '读取受保护数据失败', + { deadlineAt: Date.now() + 20 }, + ); + + await expect(request).rejects.toMatchObject({ name: 'TimeoutError' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(response.text).toHaveBeenCalledTimes(1); + + responseBody.resolve(''); + await responseBody.promise; + }, + ); + it('surfaces response metadata through ApiClientError', async () => { setStoredAccessToken('metadata-token', { emit: false }); fetchMock.mockResolvedValueOnce( diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 1c19e8f16..94211f5e0 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -49,6 +49,12 @@ export type ApiRequestOptions = { requestId?: string; }; +export type ApiJsonRequestOptions = ApiRequestOptions & { + // 从 requestJson 入口起覆盖鉴权、重试、业务请求和响应体读取的绝对截止时间。 + // 未传时保持既有 timeoutMs 仅约束单次业务 fetch 的语义。 + deadlineAt?: number; +}; + export const BACKGROUND_AUTH_REQUEST_OPTIONS = { authImpact: 'local', skipRefresh: true, @@ -317,6 +323,93 @@ function composeAbortSignal( }; } +function composeAbsoluteDeadlineSignal( + signal: AbortSignal | undefined, + deadlineAt: number | undefined, +) { + const hasDeadline = + typeof deadlineAt === 'number' && Number.isFinite(deadlineAt); + if (!hasDeadline) { + return { + signal, + hasDeadline: false, + cleanup: () => {}, + }; + } + + const controller = new AbortController(); + const remainingMs = Math.max(0, deadlineAt - Date.now()); + let timeoutId: ReturnType | undefined; + const cleanup = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + signal?.removeEventListener('abort', onAbort); + }; + const onAbort = () => { + controller.abort(signal?.reason ?? createAbortError()); + }; + + if (signal?.aborted) { + controller.abort(signal.reason ?? createAbortError()); + } else { + signal?.addEventListener('abort', onAbort, { once: true }); + if (remainingMs <= 0) { + controller.abort(createTimeoutError(0)); + } else { + timeoutId = setTimeout(() => { + controller.abort(createTimeoutError(remainingMs)); + }, remainingMs); + } + } + + return { + signal: controller.signal, + hasDeadline: true, + cleanup, + }; +} + +function awaitWithAbortSignal( + work: Promise, + signal?: AbortSignal, +): Promise { + if (!signal) { + return work; + } + + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + signal.removeEventListener('abort', onAbort); + }; + const settle = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + cleanup(); + callback(); + }; + const onAbort = () => { + settle(() => reject(signal.reason ?? createAbortError())); + }; + + signal.addEventListener('abort', onAbort, { once: true }); + work.then( + (value) => { + settle(() => resolve(value)); + }, + (error: unknown) => { + settle(() => reject(error)); + }, + ); + if (signal.aborted) { + onAbort(); + } + }); +} + async function waitForRetry(ms: number, signal?: AbortSignal) { if (ms <= 0) { return; @@ -451,6 +544,31 @@ function resolveAuthFailurePolicy( }; } +// 中文注释:这些错误体由 Pingora 网关自己合成,应用层从未产生过——它们只有 code / message, +// 没有 details,所以任何「有 details 标记就是已知结果」的判定对它们都不成立。 +// 只收上游/代理这三类:请求可能已经到达 api-server 并被执行完,只是响应没回到客户端, +// 属于未知结果。GATEWAY_RATE_LIMITED / GATEWAY_CONCURRENCY_LIMITED / PAYLOAD_TOO_LARGE +// 是在网关就被拒、根本没到应用,属于确定失败,收进来会让普通节流也弹出「请核对素材库」, +// 变成反向谎报。 +const GATEWAY_UNKNOWN_OUTCOME_ERROR_CODES = new Set([ + 'GATEWAY_UPSTREAM_ERROR', + 'GATEWAY_UPSTREAM_TIMEOUT', + 'GATEWAY_PROXY_ERROR', +]); + +/** + * 中文注释:该错误是否由网关合成、因而无法证明应用层没有执行过这次请求。 + * + * 有副作用的写接口在判断「结果是否已知」时必须把它算作未知:拿到 HTTP 响应不等于服务端 + * 明确表态过,网关超时或上游断连时 api-server 可能已经完成了持久化。 + */ +export function isGatewayUnknownOutcomeError(error: unknown): boolean { + return ( + error instanceof ApiClientError && + GATEWAY_UNKNOWN_OUTCOME_ERROR_CODES.has(error.code) + ); +} + export class ApiClientError extends Error { status: number; code: string; @@ -677,14 +795,17 @@ export async function fetchWithApiAuth( try { // 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票, // 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。 - await ensureStoredAccessToken(); + await awaitWithAbortSignal(ensureStoredAccessToken(), requestSignal); requestHeaders = withAuthorizationHeaders(init.headers, options); requestHeaders[REQUEST_ID_HEADER] = requestId; hasAuthHeader = Boolean( requestHeaders.Authorization?.trim() || requestHeaders.authorization?.trim(), ); - } catch { + } catch (error) { + if (requestSignal?.aborted) { + throw requestSignal.reason ?? error; + } // 补票失败时继续走原始请求,让调用方按真实 401 分支处理。 } } @@ -710,13 +831,16 @@ export async function fetchWithApiAuth( !refreshAttempted ) { try { - await refreshAccessToken(); + await awaitWithAbortSignal(refreshAccessToken(), requestSignal); refreshAttempted = true; // refresh 成功只代表 access token 已补票成功, // 不能把当前业务请求的首次 401 直接放大成全局鉴权变更, // 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。 continue; } catch (refreshError) { + if (requestSignal?.aborted) { + throw requestSignal.reason ?? refreshError; + } const shouldClearAuth = hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized && @@ -746,6 +870,9 @@ export async function fetchWithApiAuth( return response; } } catch (error) { + if (requestSignal?.aborted) { + throw requestSignal.reason ?? error; + } if (!shouldRetryError(error, attempt, retry)) { throw error; } @@ -759,8 +886,9 @@ export async function fetchWithApiAuth( async function buildApiClientError( response: Response, fallbackMessage: string, + signal?: AbortSignal, ) { - const responseText = await response.text(); + const responseText = await awaitWithAbortSignal(response.text(), signal); const parsedError = parseApiErrorShape(responseText); const requestId = parsedError?.meta.requestId ?? @@ -795,17 +923,42 @@ export async function requestJson( url: string, init: RequestInit, fallbackMessage: string, - options: ApiRequestOptions = {}, + options: ApiJsonRequestOptions = {}, ): Promise { - const response = await fetchWithApiAuth(url, init, options); + const lifecycle = composeAbsoluteDeadlineSignal( + init.signal ?? undefined, + options.deadlineAt, + ); + const requestOptions = lifecycle.hasDeadline + ? { ...options, timeoutMs: undefined } + : options; + const requestInit = lifecycle.signal + ? { ...init, signal: lifecycle.signal } + : init; - if (!response.ok) { - throw await buildApiClientError(response, fallbackMessage); + try { + const response = await fetchWithApiAuth(url, requestInit, requestOptions); + + if (!response.ok) { + throw await buildApiClientError( + response, + fallbackMessage, + lifecycle.signal, + ); + } + + const responseText = await awaitWithAbortSignal( + response.text(), + lifecycle.signal, + ); + if (lifecycle.signal?.aborted) { + throw lifecycle.signal.reason ?? createAbortError(); + } + + return responseText + ? unwrapApiResponse(JSON.parse(responseText) as T) + : (null as T); + } finally { + lifecycle.cleanup(); } - - const responseText = await response.text(); - - return responseText - ? unwrapApiResponse(JSON.parse(responseText) as T) - : (null as T); } diff --git a/src/services/image-editor/editorMediaAssetUploadClient.test.ts b/src/services/image-editor/editorMediaAssetUploadClient.test.ts index 37d1268bb..4309d79a6 100644 --- a/src/services/image-editor/editorMediaAssetUploadClient.test.ts +++ b/src/services/image-editor/editorMediaAssetUploadClient.test.ts @@ -35,7 +35,7 @@ describe('editorMediaAssetUploadClient', () => { vi.unstubAllGlobals(); }); - it('uploads editor MP4 assets through direct OSS upload and confirms object metadata', async () => { + it('uploads editor MP4 assets and forwards one abort signal through signed-url resolution', async () => { requestJsonMock .mockResolvedValueOnce({ upload: { @@ -63,16 +63,19 @@ describe('editorMediaAssetUploadClient', () => { getSignedAssetReadUrlMock.mockResolvedValueOnce( 'https://signed.example.com/demo.mp4', ); + const abortController = new AbortController(); const result = await uploadEditorMediaAssetFile( new File(['video'], 'demo.mp4', { type: 'video/mp4' }), 'video', + { signal: abortController.signal }, ); expect(requestJsonMock).toHaveBeenNthCalledWith( 1, '/api/assets/direct-upload-tickets', expect.objectContaining({ + signal: abortController.signal, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: expect.any(String), @@ -99,6 +102,7 @@ describe('editorMediaAssetUploadClient', () => { expect(fetch).toHaveBeenCalledWith( 'https://oss.example.com', expect.objectContaining({ + signal: abortController.signal, method: 'POST', body: expect.any(FormData), }), @@ -110,6 +114,7 @@ describe('editorMediaAssetUploadClient', () => { 2, '/api/assets/objects/confirm', expect.objectContaining({ + signal: abortController.signal, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: expect.any(String), @@ -119,7 +124,8 @@ describe('editorMediaAssetUploadClient', () => { ); expect(confirmBody).toMatchObject({ bucket: 'bucket', - objectKey: 'generated-character-drafts/editor/asset-library/video/demo.mp4', + objectKey: + 'generated-character-drafts/editor/asset-library/video/demo.mp4', contentType: 'video/mp4', contentLength: 5, assetKind: 'editor_uploaded_video', @@ -132,7 +138,7 @@ describe('editorMediaAssetUploadClient', () => { 'generated-character-drafts/editor/asset-library/video/demo.mp4', expireSeconds: 3600, }, - undefined, + abortController.signal, { bypassCache: true }, ); expect(result).toEqual({ @@ -194,7 +200,8 @@ describe('editorMediaAssetUploadClient', () => { (requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string, ); expect(confirmBody).toMatchObject({ - objectKey: 'generated-character-drafts/editor/asset-library/image/spec.png', + objectKey: + 'generated-character-drafts/editor/asset-library/image/spec.png', contentType: 'image/png', contentLength: 5, assetKind: 'editor_uploaded_image', @@ -260,12 +267,7 @@ describe('editorMediaAssetUploadClient', () => { (requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string, ); expect(ticketBody).toMatchObject({ - pathSegments: [ - 'editor', - 'project-covers', - 'project-1', - '1771400000000', - ], + pathSegments: ['editor', 'project-covers', 'project-1', '1771400000000'], fileName: 'project-1-cover.png', metadata: { asset_kind: 'editor_project_cover_snapshot', diff --git a/src/services/image-editor/editorMediaAssetUploadClient.ts b/src/services/image-editor/editorMediaAssetUploadClient.ts index 366676891..2437878a6 100644 --- a/src/services/image-editor/editorMediaAssetUploadClient.ts +++ b/src/services/image-editor/editorMediaAssetUploadClient.ts @@ -42,6 +42,9 @@ export type EditorMediaAssetUploadOptions = { pathSegments?: string[]; entityId?: string; metadata?: Record; + // 中文注释:贯穿凭证、直传与 confirm 三步。只中止直传会留下未 confirm 的 OSS 对象, + // 只中止 confirm 又会让实体已写入却无记录——要停就整条链一起停。 + signal?: AbortSignal; }; const EDITOR_MEDIA_READ_EXPIRE_SECONDS = 60 * 60; @@ -86,6 +89,7 @@ export async function uploadEditorMediaAssetObjectFile( const ticket = await requestJson( '/api/assets/direct-upload-tickets', { + signal: options.signal, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -112,11 +116,17 @@ export async function uploadEditorMediaAssetObjectFile( { retry: EDITOR_REQUEST_RETRY_OPTIONS }, ); - await postEditorDirectUploadFile(ticket.upload, file, '上传素材失败'); + await postEditorDirectUploadFile( + ticket.upload, + file, + '上传素材失败', + options.signal, + ); const confirmed = await requestJson( '/api/assets/objects/confirm', { + signal: options.signal, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -156,7 +166,7 @@ export async function uploadEditorMediaAssetFile( objectKey: uploaded.objectKey, expireSeconds: EDITOR_MEDIA_READ_EXPIRE_SECONDS, }, - undefined, + options.signal, { bypassCache: true }, ); return { diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index b9323ec3e..6100a7df1 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -25,6 +25,7 @@ import { removeEditorImageBackground, renameEditorProject, saveEditorProjectLayout, + snapEditorImageToPixelArt, splitEditorIconSpritesheet, submitEditorAssetShowcase, toggleEditorShowcaseAssetLike, @@ -154,6 +155,7 @@ describe('editorProjectClient', () => { }); it('saves viewport and layer layout through the project API', async () => { + const startedAt = Date.now(); requestJsonMock.mockResolvedValueOnce({ projectId: 'editor-project-1', canvasId: 'editor-project-1:canvas:default', @@ -187,7 +189,15 @@ describe('editorProjectClient', () => { }), }), '保存图片画布工程失败', + { deadlineAt: expect.any(Number) }, ); + const requestOptions = requestJsonMock.mock.calls[0]?.[3] as + | { deadlineAt?: number } + | undefined; + expect(requestOptions?.deadlineAt).toBeGreaterThanOrEqual( + startedAt + 60_000, + ); + expect(requestOptions?.deadlineAt).toBeLessThanOrEqual(Date.now() + 60_000); }); it('lists editor projects from the project API', async () => { @@ -328,6 +338,70 @@ describe('editorProjectClient', () => { '/api/editor/projects/editor-project-1', { method: 'GET' }, '读取图片画布工程失败', + // 中文注释:同上,超时写死在断言里。这是未知结果对账路径上的读取,挂住会让 catch + // 迟迟不结束,连带把本会话对占位的归属登记一起拖过存活窗口。 + { timeoutMs: 60_000 }, + ); + }); + + it('forwards an abort signal and custom timeout when loading a project', async () => { + const controller = new AbortController(); + requestJsonMock.mockResolvedValueOnce({ + project: { + projectId: 'editor-project-1', + title: '角色设定板', + canvas: { + canvasId: 'editor-project-1:canvas:default', + projectId: 'editor-project-1', + title: '默认画布', + viewport: { x: 8, y: 9, scale: 1.5 }, + layers: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }, + viewport: { x: 8, y: 9, scale: 1.5 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }, + }); + + await loadEditorProject('editor-project-1', { + signal: controller.signal, + timeoutMs: 5_000, + }); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/projects/editor-project-1', + { method: 'GET', signal: controller.signal }, + '读取图片画布工程失败', + { timeoutMs: 5_000 }, + ); + }); + + it('forwards an absolute lifecycle deadline when loading a project', async () => { + const controller = new AbortController(); + const deadlineAt = Date.now() + 10_000; + requestJsonMock.mockResolvedValueOnce({ + project: { + projectId: 'editor-project-1', + title: '角色设定板', + viewport: { x: 8, y: 9, scale: 1.5 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }, + }); + + await loadEditorProject('editor-project-1', { + signal: controller.signal, + deadlineAt, + }); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/projects/editor-project-1', + { method: 'GET', signal: controller.signal }, + '读取图片画布工程失败', + { deadlineAt }, ); }); @@ -1745,4 +1819,103 @@ describe('editorProjectClient', () => { }), ); }); + + it('submits a stable perfect-pixel source without unsafe POST retries', async () => { + requestJsonMock.mockResolvedValueOnce({ + imageSrc: '/generated-images/editor/perfect-pixel.png', + objectKey: 'generated-images/editor/perfect-pixel.png', + assetObjectId: 'asset-object-perfect-pixel', + width: 1024, + height: 768, + sourceType: 'generated', + taskId: 'perfect-pixel-1', + elapsedMs: 321, + provider: 'Genarrative', + resource: { resourceId: 'resource-perfect-pixel' }, + asset: { assetId: 'asset-perfect-pixel' }, + project: null, + }); + + const result = await snapEditorImageToPixelArt({ + sourceImageSrc: 'generated-images/editor/source.png', + projectId: 'editor-project-1', + sourceResourceId: 'resource-source', + assetKind: 'character', + generationInputs: { + fields: [{ title: '角色设定', value: '红发骑士' }], + references: [], + }, + assetFolderId: 'project', + assetLabel: '源图 · 完美像素', + canvasCompletion: { + dialogId: 'generation-dialog-perfect-pixel', + title: '源图 · 完美像素', + placeholder: { + x: 472, + y: 140, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }); + + expect(result.taskId).toBe('perfect-pixel-1'); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/images/pixel-art-snaps', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceImageSrc: 'generated-images/editor/source.png', + projectId: 'editor-project-1', + sourceResourceId: 'resource-source', + assetKind: 'character', + generationInputs: { + fields: [{ title: '角色设定', value: '红发骑士' }], + references: [], + }, + assetFolderId: 'project', + assetLabel: '源图 · 完美像素', + canvasCompletion: { + dialogId: 'generation-dialog-perfect-pixel', + title: '源图 · 完美像素', + placeholder: { + x: 472, + y: 140, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }), + }, + '完美像素处理失败', + { timeoutMs: 120_000 }, + ); + }); + + it('rejects inline perfect-pixel media before sending the request', async () => { + await expect( + snapEditorImageToPixelArt({ + sourceImageSrc: 'data:image/png;base64,source', + projectId: 'editor-project-1', + canvasCompletion: { + dialogId: 'generation-dialog-perfect-pixel', + title: '源图 · 完美像素', + placeholder: { + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }), + ).rejects.toThrow('待完美像素处理图片必须先上传 OSS'); + expect(requestJsonMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 8157f4b8b..3135301fb 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -10,6 +10,7 @@ const EDITOR_PROJECT_RESOURCE_API_BASE = '/api/editor/project-resources'; const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations'; const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits'; const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals'; +const EDITOR_PIXEL_ART_SNAP_API = '/api/editor/images/pixel-art-snaps'; const EDITOR_ICON_SPRITESHEET_GENERATION_API = '/api/editor/icon-spritesheets/generations'; const EDITOR_ICON_SPRITESHEET_SLICE_API = @@ -292,6 +293,19 @@ export type EditorBackgroundRemovalInput = { canvasCompletion?: EditorCanvasGenerationCompletionInput | null; }; +export type EditorPixelArtSnapInput = { + sourceImageSrc: string; + projectId: string; + sourceResourceId?: string | null; + assetKind?: string | null; + generationInputs?: EditorAssetGenerationInputs | null; + assetFolderId?: string | null; + assetLabel?: string | null; + canvasCompletion: EditorCanvasGenerationCompletionInput & { + dialogId: string; + }; +}; + export type EditorImageGenerationResult = { imageSrc: string; objectKey?: string | null; @@ -314,6 +328,21 @@ export type EditorBackgroundRemovalResult = { queueState: ExternalGenerationJobStatusRecord; }; +export type EditorPixelArtSnapResult = { + imageSrc: string; + objectKey: string; + assetObjectId: string; + width: number; + height: number; + sourceType: 'generated'; + taskId: string; + elapsedMs: number; + provider: 'Genarrative'; + resource: EditorProjectResourceSnapshot; + asset: EditorAssetSnapshot; + project: EditorProjectSnapshot | null; +}; + export type EditorIconSpritesheetIconResult = { name: string; imageSrc: string; @@ -536,6 +565,12 @@ export type EditorCanvasSnapshot = { updatedAt: string; }; +export type EditorProjectLoadOptions = { + signal?: AbortSignal; + timeoutMs?: number; + deadlineAt?: number; +}; + export type EditorProjectCreateInput = { title?: string; }; @@ -643,6 +678,7 @@ type EditorShowcaseAssetResponse = { type EditorImageGenerationResponse = EditorImageGenerationResult; type EditorBackgroundRemovalResponse = EditorBackgroundRemovalResult; +type EditorPixelArtSnapResponse = EditorPixelArtSnapResult; type EditorIconSpritesheetGenerationResponse = EditorIconSpritesheetGenerationResult; type EditorVideoGenerationResponse = EditorVideoGenerationResult; @@ -738,11 +774,25 @@ export async function loadEditorGenerationPricing() { ); } -export async function loadEditorProject(projectId: string) { +export async function loadEditorProject( + projectId: string, + options: EditorProjectLoadOptions = {}, +) { + const hasAbsoluteDeadline = + typeof options.deadlineAt === 'number' && + Number.isFinite(options.deadlineAt); const response = await requestJson( `${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`, - { method: 'GET' }, + { + method: 'GET', + ...(options.signal ? { signal: options.signal } : {}), + }, '读取图片画布工程失败', + hasAbsoluteDeadline + ? { deadlineAt: options.deadlineAt } + : // 中文注释:普通项目读取继续保留既有单次 fetch timeout;完美像素对账显式传 + // deadlineAt,改由 requestJson 从鉴权恢复到响应体读取约束完整生命周期。 + { timeoutMs: options.timeoutMs ?? 60_000 }, ); return response.project; } @@ -765,6 +815,9 @@ export async function deleteEditorProject(projectId: string) { return response.deletedProjectId; } +// 中文注释:这里曾接受第三参数(调用方注入的 `signal` / `deadlineAt`),只服务于已删除的 +// 严格布局保存通道;该通道删除后全部调用点都只传两个参数,注入分支恒不生效。一并摘掉, +// 免得后来者以为调用方还能控制这次保存的取消与截止。 export async function saveEditorProjectLayout( projectId: string, input: EditorProjectLayoutSaveInput, @@ -777,6 +830,8 @@ export async function saveEditorProjectLayout( expectedRevision: input.expectedRevision, }), '保存图片画布工程失败', + // 布局保存会被生成提交链同步等待,必须连鉴权恢复和响应体读取一起有界。 + { deadlineAt: Date.now() + 60_000 }, ); } @@ -1096,6 +1151,33 @@ export async function removeEditorImageBackground( ); } +export async function snapEditorImageToPixelArt( + input: EditorPixelArtSnapInput, +) { + assertStableEditorMediaReference(input.sourceImageSrc, '待完美像素处理图片'); + return requestJson( + EDITOR_PIXEL_ART_SNAP_API, + jsonRequest('POST', { + sourceImageSrc: input.sourceImageSrc, + projectId: input.projectId, + ...(input.sourceResourceId + ? { sourceResourceId: input.sourceResourceId } + : {}), + ...(input.assetKind ? { assetKind: input.assetKind } : {}), + ...(input.generationInputs + ? { generationInputs: input.generationInputs } + : {}), + ...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}), + ...(input.assetLabel ? { assetLabel: input.assetLabel } : {}), + canvasCompletion: input.canvasCompletion, + }), + '完美像素处理失败', + { + timeoutMs: 120_000, + }, + ); +} + export async function generateEditorCharacterAnimation( input: EditorCharacterAnimationGenerationInput, ) { diff --git a/src/services/image-editor/editorRetryOptions.ts b/src/services/image-editor/editorRetryOptions.ts index ecbd86d15..30443b67c 100644 --- a/src/services/image-editor/editorRetryOptions.ts +++ b/src/services/image-editor/editorRetryOptions.ts @@ -53,11 +53,15 @@ export async function postEditorDirectUploadFile( upload: EditorDirectUploadTarget, file: File, errorMessage: string, + // 中文注释:调用方超时后仅停止 await 是不够的——这一步是唯一往 OSS 写实体的动作, + // 不取消的话被放弃的上传会继续跑完并注册对象,用户重试再产生一份,留下不可见的孤儿。 + signal?: AbortSignal, ) { for (let attempt = 0; ; attempt += 1) { const response = await fetch(upload.host, { method: 'POST', body: buildDirectUploadFormData(upload, file), + signal, }); if (response.ok) { From e5ebdf0a8ed0801cbad5a4a8b60d0ba4e4d8061a Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 5 Aug 2026 21:48:30 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E8=80=85=E5=AF=86=E9=92=A5=E6=97=B6=E9=97=B4=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用个人中心时间解析逻辑格式化秒级小数时间戳 统一展示密钥创建时间和最近使用时间 补充弹窗回归测试与接入文档约束 --- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 2 +- .../PlatformProfileApiKeysModal.tsx | 10 +---- .../platformProfileApiKeysModal.test.ts | 45 +++++++++++++++++++ 3 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 src/components/platform-entry/platformProfileApiKeysModal.test.ts diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 876b2e86b..37a438a64 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -123,7 +123,7 @@ POST /api/profile/api-keys DELETE /api/profile/api-keys/{keyId} ``` -前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。外部 OpenAPI 只描述 `/api/external/v1` 下可由 API Key 调用的接口,不混入登录态 API Key 管理接口。 +前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。Key 卡片中的创建时间和最近使用时间必须格式化为 `YYYY-MM-DD`,不得直接展示后端时间戳。外部 OpenAPI 只描述 `/api/external/v1` 下可由 API Key 调用的接口,不混入登录态 API Key 管理接口。 ## 版本与兼容策略 diff --git a/src/components/platform-entry/PlatformProfileApiKeysModal.tsx b/src/components/platform-entry/PlatformProfileApiKeysModal.tsx index 1ee0a4758..108280cef 100644 --- a/src/components/platform-entry/PlatformProfileApiKeysModal.tsx +++ b/src/components/platform-entry/PlatformProfileApiKeysModal.tsx @@ -21,6 +21,7 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { useCopyFeedback } from '../common/useCopyFeedback'; import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell'; +import { formatPlatformProfileTime } from './platformProfileFundsModel'; type PlatformProfileApiKeysModalProps = { onClose: () => void; @@ -34,14 +35,7 @@ function buildApiKeyTimeLabel(value: string | null) { if (!value) { return '尚未使用'; } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - const year = date.getUTCFullYear(); - const month = String(date.getUTCMonth() + 1).padStart(2, '0'); - const day = String(date.getUTCDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + return formatPlatformProfileTime(value); } function buildApiKeyScopeLabel(scopes: string[]) { diff --git a/src/components/platform-entry/platformProfileApiKeysModal.test.ts b/src/components/platform-entry/platformProfileApiKeysModal.test.ts new file mode 100644 index 000000000..ebd15fec5 --- /dev/null +++ b/src/components/platform-entry/platformProfileApiKeysModal.test.ts @@ -0,0 +1,45 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { createElement } from 'react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { PlatformProfileApiKeysModal } from './PlatformProfileApiKeysModal'; + +const listExternalApiKeysMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../services/platform-entry/platformProfileClient', () => ({ + createPlatformProfileExternalApiKey: vi.fn(), + listPlatformProfileExternalApiKeys: listExternalApiKeysMock, + revokePlatformProfileExternalApiKey: vi.fn(), +})); + +describe('PlatformProfileApiKeysModal', () => { + beforeEach(() => { + listExternalApiKeysMock.mockReset(); + }); + + test('将 API Key 时间戳格式化为日期', async () => { + listExternalApiKeysMock.mockResolvedValueOnce({ + keys: [ + { + keyId: 'api-key-1', + name: '外部 API Key', + keyPrefix: 'tnr_sk_example', + scopes: ['editor:project'], + createdAt: '1785913407.182731Z', + lastUsedAt: null, + revokedAt: null, + updatedAt: '1785913407.182731Z', + }, + ], + }); + + render(createElement(PlatformProfileApiKeysModal, { onClose: vi.fn() })); + + expect( + await screen.findByText('创建 2026-08-05 · 最近使用 尚未使用'), + ).toBeTruthy(); + expect(screen.queryByText(/1785913407\.182731Z/u)).toBeNull(); + }); +}); From 6f181b36a132c05f7dbca2de5c27feb59a6f7899 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 5 Aug 2026 21:33:02 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E7=94=9F=E6=88=90=E5=B9=82=E7=AD=89=E4=B8=8E=E5=8F=82?= =?UTF-8?q?=E8=80=83=E5=9B=BE=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成队列按稳定请求标识去重并保持外部幂等哈希兼容 统一拒绝参考图超限并同步前端、后端、Provider 与 OpenAPI 契约 按真实归属重建生成引用并阻止直接持久化伪造来源 锁定参考图在途上传上下文并保留批量部分成功结果 关闭内部生成 POST 自动重试并补齐回归测试与项目文档 修正最新主线开发者密钥弹窗的导入排序门禁 --- .../genarrative-external-v1.openapi.json | 8 +- .../shared-memory/decision-log.md | 7 + docs/project-memory/shared-memory/pitfalls.md | 7 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- .../api-server/src/editor_generation_queue.rs | 318 +++++++- .../crates/api-server/src/editor_project.rs | 649 ++++++++++++++- .../api-server/src/external_editor_api.rs | 36 +- .../src/vector_engine/client.rs | 98 ++- .../src/vector_engine/constants.rs | 2 + .../src/vector_engine/curl_transport.rs | 2 +- .../src/vector_engine/image_source.rs | 16 +- .../src/vector_engine/request.rs | 10 +- ...ImageCanvasBasicGenerationComposerView.tsx | 3 + ...eCanvasCharacterGenerationComposerView.tsx | 3 + .../image-editor/ImageCanvasEditorTypes.ts | 1 + .../image-editor/ImageCanvasEditorView.tsx | 199 ++++- .../ImageCanvasGenerationComposerView.tsx | 9 + .../ImageCanvasGenerationDialogModel.test.ts | 87 ++ .../ImageCanvasGenerationDialogModel.ts | 44 +- ...eCanvasGenerationImageOptionsView.test.tsx | 100 ++- .../ImageCanvasGenerationImageOptionsView.tsx | 20 +- .../ImageCanvasGenerationModel.ts | 109 ++- ...ImageCanvasIconSpritesheetComposerView.tsx | 7 +- ...anvasPublicationMaterialsDemoPanelView.tsx | 3 + .../ImageCanvasQuickEditPanelView.tsx | 16 +- .../ImageCanvasSpecGenerationPanelView.tsx | 5 +- .../image-editor/ImageCanvasStageView.tsx | 3 + .../ImageCanvasUiAssetExtractionModel.ts | 1 + ...anvasUiAssetExtractionOverlayView.test.tsx | 73 ++ ...mageCanvasUiAssetExtractionOverlayView.tsx | 21 +- .../image-editor/ImageCanvasUploadModel.ts | 55 +- .../useCanvasGenerationDialogs.test.tsx | 72 ++ .../useCanvasGenerationDialogs.ts | 92 ++- .../useImageCanvasAssetCanvasBridge.test.tsx | 51 +- .../useImageCanvasAssetCanvasBridge.ts | 26 +- .../useImageCanvasAssetLibrary.test.tsx | 28 +- .../useImageCanvasAssetLibrary.ts | 10 +- .../useImageCanvasGenerationSurface.tsx | 4 + .../useImageCanvasGenerationWorkflow.test.tsx | 143 ++++ .../useImageCanvasGenerationWorkflow.ts | 451 ++++++++-- .../useImageCanvasLayerCommands.test.tsx | 53 +- .../useImageCanvasLayerCommands.ts | 30 + .../useImageCanvasUploadWorkflow.test.tsx | 369 ++++++++- .../useImageCanvasUploadWorkflow.ts | 767 +++++++++++++----- .../PlatformProfileApiKeysModal.tsx | 2 +- .../image-editor/editorProjectClient.test.ts | 78 +- .../image-editor/editorProjectClient.ts | 108 ++- .../image-editor/editorRetryOptions.ts | 7 + 49 files changed, 3731 insertions(+), 480 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 6c65f059f..1ddc9543d 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3047,7 +3047,7 @@ "type": "array", "items": { "type": "string", - "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多使用前 5 张,数组上限为 9。" + "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 gpt-image-2 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。" }, "maxItems": 9 }, @@ -3241,7 +3241,7 @@ "type": "array", "items": { "type": "string", - "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。" + "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" }, "maxItems": 8 }, @@ -3372,7 +3372,7 @@ "type": "array", "items": { "type": "string", - "description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。最多 8 张。" + "description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" }, "maxItems": 8 }, @@ -3496,7 +3496,7 @@ "type": "array", "items": { "type": "string", - "description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。最多 5 张。" + "description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。" }, "maxItems": 5 }, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a8fd39af2..0aa45df6a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6574,3 +6574,10 @@ - 遗留(建议单开,不在本次范围):`loadProjectCoverImage` 里无 timeout / 无 AbortSignal 的 `new Image()` 本身仍是隐患,自动保存路径一样会踩。本次只是把它移出生成链的关键路径,没有消除它。 - 影响范围:`useImageCanvasProjectPersistence.ts` 的 `flushProjectPersistence`。不改服务端、不改契约。 - 验证方式:既有用例「flush 等待封面缓存」翻转为「flush 不等封面、但封面链照常跑完并完成上传与资源登记」;新增「封面永不 settle 时 flush 仍返回」——用永不 resolve 的 blob 模拟 `new Image()` 不 settle,并断言 `createProjectCoverSnapshotBlob` 确实被调用过以防用例空过。已实证:回退修复后新用例报 `expected 'false' to be 'true'`。运行 `npx vitest run src/components/image-editor src/components/platform-entry src/services`(101 文件 / 1241 项)、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +## 2026-08-05 编辑器生成请求与参考图权威契约 + +- 主站编辑器生成 POST 不做浏览器自动重试,避免 inline 模式在响应丢失后重复调用 provider;api-server 仍使用独立 namespace + owner + job kind + request id 生成队列 `dedupe_key`,让显式复用同一请求标识的队列重放原子返回已存在任务,并对同键不同 payload 返回 `409`。外部 v1 的 `Idempotency-Key` 保持独立 namespace。 +- 参考图数量以产品上限与 provider 容量的较小值为准,前端添加 / 上传 / 提交、api-server 入队与执行、`platform-image` provider 边界均明确拒绝超限;任何层都不再用 `.take(...)` 把第 N+1 张静默丢弃。角色 / UI 从一开始预留主图槽位;并发上传计入在途数量并在持久化前复验。任一参考图上传批次在途时锁定模型切换、画布选图、提交生成、关联源图删除 / 剪切 / 素材删除及生成面板切换 / 关闭,并以原面板上下文标识在持久化前后复验,完成或失败并释放 reservation 后才允许继续操作;批量部分失败时仍挂接成功项并刷新素材库。模型降容或后补主图会超限时拒绝操作并保留现有引用。 +- 图片类最终 `generationInputs.references` 不信任客户端输入;队列 payload、完美像素及直接创建资源 / 素材入口删除客户端 references,worker / inline 路径按本次真实参考源与 owner 范围内的项目资源、账号素材重建 `refType/refId`。只有 owned objectKey 但没有正式行时不生成伪 provenance。完美像素继续使用升级前 canonical 客户端输入计算 operation fingerprint;新操作只持久化权威重建值,历史同 task/resource 重放复用服务端既存 metadata 通过精确比较。 +- 升级前 External 幂等任务可能仍在 payload 中保留客户端 references;重放比较只对白名单内已迁移的图片生成、图片修改、去背景、图标图集和 UI 提取任务,在旧侧有 references、当前侧已删除时移除旧字段,其他字段变化仍返回 `409`。音频 / 视频 / 角色动作等未迁移 job kind 始终完整比较,不能扩大兼容面。 +- 本次复用既有 `external_generation_job.dedupe_key` 唯一索引和 `spacetime-client` 查询,不改 SpacetimeDB schema、迁移或 bindings。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 430d0258c..fe42622ad 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4195,3 +4195,10 @@ - 原因:两个跨模块测试读写同一进程全局状态,却没有共用隔离边界;只给 accept 后取得的 stream 设置 read timeout 无法约束 accept 本身,payload 读取也缺少总 deadline。 - 处理:全部全局 sink 测试共用一把 test-only 串行锁,并由 RAII guard 在 `Drop` 中无条件清空;测试统一使用 `manifest_invalidation_sink_isolation_` 前缀。relay fixture 对 accept 和 payload 分别使用非阻塞轮询与总 deadline,不使用固定 sleep;生产 loopback、token、连接 / 写入超时和 payload 大小校验保持不变。 - 验证:用 `--test-threads=2` 重复运行统一 filter,覆盖正常 relay、无事件 accept 超时、不完整 payload 超时、panic 展开清理,以及 GUI owner attach 配置与 guard 清理。 + +## 编辑器生成不能把传输重试、参考图截断和客户端 provenance 当成独立小问题(2026-08-05) + +- 现象:生成 POST 首次已经入队但响应丢失时,客户端自动重试产生第二个任务;第 6 张或更多参考图仍显示在 UI / 元数据里,却没有送给 provider;直接构造请求还能把任意资源 ID 写成最终素材引用。 +- 原因:客户端虽在重试中复用 `x-request-id`,队列入口却用随机 job id 生成 dedupe key;前端允许无限追加,api-server 和 provider 用 `.take(...)` 静默截断;`generationInputs.references` 被当成可信持久 provenance。 +- 处理:主站生成 POST 禁止自动重试,把显式复用的稳定 request id 接到队列唯一键并校验 replay payload;所有边界显式拒绝超限,前端还要预留主图槽位、统计在途上传,并在上传完成前拒绝模型切换、画布选图、提交生成、关联源图删除 / 剪切 / 素材删除和面板切换 / 关闭;reservation 必须绑定原面板上下文,批量部分失败时不能丢弃已经持久化的成功项。入队、完美像素及直接创建资源 / 素材时删除客户端 references,执行时按真实参考源和 owner 资源记录重建权威引用。历史任务比较必须兼容仅差已删除 references 的旧 payload,不能只保留旧 hash 却让 payload 比较误报冲突。 +- 验证:覆盖同键同 payload / 不同 payload、普通图片第 6 张、带主图的 GPT-image-2 第 5 张额外引用、provider 6 / 15 张边界、伪造引用删除和 owned 资源 / 素材重建。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 4871d3a8a..e5fdfaf35 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -34,6 +34,10 @@ - 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 +- `generationInputs.references` 的 `refType/refId` 是服务端权威行引用:客户端提交的 references 只属于非权威展示候选,api-server 入队及直接创建资源 / 素材时删除,生成执行时按真实参考图和当前 owner 的资源 / 素材记录重建后再持久化;裸 owned objectKey 找不到正式资源或素材行时可以参与生成,但不得制造伪引用。`title/label` 只作为展示快照,不提升为资源身份。 +- 普通图片生成最多选择 5 张参考图;带主图 / 规范图的图片修改、图标素材和 UI 素材提取需要从打开面板起预留这 1 张主引用,再与 provider 容量取最小值(GPT-image-2 总计 5 张,nanobanana2 总计 14 张)。画布选择、上传和最终提交都必须阻止第 N+1 张进入请求;并发上传要把在途批次计入容量,并在创建项目资源 / 账号素材前按最新模型复验。reservation 必须绑定发起上传的 dialog / 快速编辑 / UI 提取上下文;任一参考图上传批次在途时,所有图片模型切换、从画布添加主图 / 规范图 / 参考图、提交生成、删除 / 剪切关联源图、删除其来源素材以及生成面板切换 / 关闭都要明确拒绝,批次持久化前后还要复验上下文未变化。批次部分失败时要保留并挂接已经成功持久化的引用,不能因其中一项失败而丢弃整批成功项;完成或失败并释放 reservation 后才允许继续操作。模型降容或后补主图若在操作当下已经超限,应保留原模型 / 原参考图并明确提示用户先删除,不得用 `slice` 静默丢弃;旧 dialog 或直接 API 请求由后端返回明确超限错误。 +- 主站编辑器生成 POST 在浏览器端不自动重试;队列模式仍按同一 `x-request-id` 幂等重放,External v1 使用显式 `Idempotency-Key`。inline 模式没有结果级幂等时,不得因 408 / 429 / 5xx 或传输异常自动再次调用 provider。 + ### 静态图片风格与像素规整边界 - 普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 三个面板增加紧凑的 `像素艺术` 勾选项;移动端可独占一行,但不增加功能说明文案。当前生成对象以 `style: "none" | "pixelArt"` 保存选择并随现有请求 / 队列 payload 传递;该字段不写入用户可见 `generationInputs`,也不新增素材元数据字段。其它生成、编辑、UI 素材提取、角色动画及画布 Agent 入口不展示或设置该选项。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 023ffc138..05bb5a2c5 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -243,8 +243,10 @@ npm run check:server-rs-ddd 6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。 7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。 8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。 -9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 +9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。 +11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。 +12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值,检测到 owner 项目中已存在同一稳定 task/resource 的历史结果时则复用该服务端既存 metadata 完成精确 compare-and-return。 ## 外部服务与资产 diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 248adf54a..22db58e8b 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -28,6 +28,7 @@ pub(crate) const EDITOR_GENERATION_QUEUE_SOURCE_MODULE: &str = "editor-canvas"; const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker"; const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024; const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation"; +const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation"; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -37,7 +38,7 @@ pub(crate) struct EditorGenerationQueuedResponse { pub(crate) async fn enqueue_editor_generation_job( state: &AppState, - _request_context: &RequestContext, + request_context: &RequestContext, owner_user_id: &str, job_kind: &str, source_entity_id: impl Into, @@ -49,6 +50,12 @@ where T: Serialize, { let job_id = build_prefixed_uuid_id("task-"); + let dedupe_key = build_editor_generation_dedupe_key( + EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX, + owner_user_id, + job_kind, + request_context.request_id().trim(), + ); enqueue_editor_generation_job_with_identity( state, owner_user_id, @@ -58,11 +65,26 @@ where price_mud_points, payload, job_id.clone(), - format!("editor-canvas:{job_kind}:{job_id}"), + dedupe_key, ) .await } +fn build_editor_generation_dedupe_key( + namespace: &str, + owner_user_id: &str, + job_kind: &str, + stable_key: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(owner_user_id.trim().as_bytes()); + hasher.update(b"\0"); + hasher.update(job_kind.trim().as_bytes()); + hasher.update(b"\0"); + hasher.update(stable_key.as_bytes()); + format!("{namespace}:{job_kind}:{:x}", hasher.finalize()) +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn enqueue_editor_generation_job_with_identity( state: &AppState, @@ -79,18 +101,25 @@ where T: Serialize, { let request_payload_json = serialize_editor_generation_job_payload(payload)?; - enqueue_serialized_editor_generation_job_with_identity( + let job = enqueue_serialized_editor_generation_job_with_identity( state, owner_user_id, job_kind, source_entity_id, request_label, price_mud_points, - request_payload_json, + request_payload_json.clone(), job_id, dedupe_key, ) - .await + .await?; + ensure_editor_generation_job_matches_request( + job, + owner_user_id, + job_kind, + request_payload_json.as_str(), + "请求幂等键已用于不同的生成请求,请复用原请求参数或更换请求标识。", + ) } #[allow(clippy::too_many_arguments)] @@ -108,15 +137,11 @@ where T: Serialize, { let request_payload_json = serialize_editor_generation_job_payload(payload)?; - let mut hasher = Sha256::new(); - hasher.update(owner_user_id.trim().as_bytes()); - hasher.update(b"\0"); - hasher.update(job_kind.trim().as_bytes()); - hasher.update(b"\0"); - hasher.update(idempotency_key.as_bytes()); - let dedupe_key = format!( - "{EXTERNAL_API_GENERATION_DEDUPE_PREFIX}:{job_kind}:{:x}", - hasher.finalize() + let dedupe_key = build_editor_generation_dedupe_key( + EXTERNAL_API_GENERATION_DEDUPE_PREFIX, + owner_user_id, + job_kind, + idempotency_key, ); let requested_job_id = build_prefixed_uuid_id("task-"); let job = enqueue_serialized_editor_generation_job_with_identity( @@ -132,20 +157,97 @@ where ) .await?; + ensure_editor_generation_job_matches_request( + job, + owner_user_id, + job_kind, + request_payload_json.as_str(), + "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。", + ) +} + +fn ensure_editor_generation_job_matches_request( + job: ExternalGenerationJobRecord, + owner_user_id: &str, + job_kind: &str, + request_payload_json: &str, + conflict_message: &str, +) -> Result { if job.job_kind != job_kind || job.owner_user_id != owner_user_id - || job.request_payload_json != request_payload_json + || !editor_generation_request_payloads_match( + job_kind, + job.request_payload_json.as_str(), + request_payload_json, + ) { return Err( AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": EDITOR_GENERATION_QUEUE_PROVIDER, - "message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。", + "message": conflict_message, })), ); } Ok(job) } +fn generation_input_references(value: &Value) -> Option<&Value> { + value + .as_object() + .and_then(|payload| payload.get("generationInputs")) + .and_then(Value::as_object) + .and_then(|generation_inputs| generation_inputs.get("references")) +} + +fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) -> bool { + let Some(generation_inputs) = value + .as_object_mut() + .and_then(|payload| payload.get_mut("generationInputs")) + .and_then(Value::as_object_mut) + else { + return false; + }; + generation_inputs.remove("references").is_some() +} + +fn job_kind_migrated_away_from_client_generation_references(job_kind: &str) -> bool { + matches!( + job_kind, + EDITOR_IMAGE_GENERATION_JOB_KIND + | EDITOR_IMAGE_EDIT_JOB_KIND + | EDITOR_BACKGROUND_REMOVAL_JOB_KIND + | EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND + | EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND + ) +} + +fn editor_generation_request_payloads_match( + job_kind: &str, + existing: &str, + requested: &str, +) -> bool { + if existing == requested { + return true; + } + let (Ok(mut existing), Ok(requested)) = ( + serde_json::from_str::(existing), + serde_json::from_str::(requested), + ) else { + return false; + }; + // 只兼容部署前旧 payload 有 references、当前 sanitizer 已删除该字段的单向迁移。 + // 音频、视频、角色动作等仍会保留 references;如果当前请求也带该字段,就必须完整 + // 比较,不能把两个不同请求错误复用成同一任务。 + if !job_kind_migrated_away_from_client_generation_references(job_kind) + || generation_input_references(&existing).is_none() + || generation_input_references(&requested).is_some() + || !strip_untrusted_generation_input_references_from_payload(&mut existing) + { + return false; + } + existing == requested +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn enqueue_editor_generation_job_for_caller( state: &AppState, @@ -363,6 +465,190 @@ mod tests { } } + #[test] + fn editor_api_request_dedupe_key_is_stable_and_namespaced() { + let first = build_editor_generation_dedupe_key( + EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX, + "user-1", + EDITOR_IMAGE_GENERATION_JOB_KIND, + "request-1", + ); + let replay = build_editor_generation_dedupe_key( + EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX, + " user-1 ", + EDITOR_IMAGE_GENERATION_JOB_KIND, + "request-1", + ); + let other_owner = build_editor_generation_dedupe_key( + EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX, + "user-2", + EDITOR_IMAGE_GENERATION_JOB_KIND, + "request-1", + ); + let external = build_editor_generation_dedupe_key( + EXTERNAL_API_GENERATION_DEDUPE_PREFIX, + "user-1", + EDITOR_IMAGE_GENERATION_JOB_KIND, + "request-1", + ); + + assert_eq!(first, replay); + assert_ne!(first, other_owner); + assert_ne!(first, external); + assert!(first.starts_with("editor-api-request-generation:editor_image_generation:")); + } + + #[test] + fn external_api_dedupe_key_preserves_legacy_hash_bytes() { + let dedupe_key = build_editor_generation_dedupe_key( + EXTERNAL_API_GENERATION_DEDUPE_PREFIX, + " user-1 ", + EDITOR_IMAGE_GENERATION_JOB_KIND, + " request-1 ", + ); + + assert_eq!( + dedupe_key, + "external-api-generation:editor_image_generation:81e38a8eace5f098041b3c240f99053c555ccfa18139295576c9b4ba3d9acff6" + ); + } + + #[test] + fn replayed_editor_generation_job_must_match_original_request() { + let mut job = queue_job_fixture("queued", None); + job.owner_user_id = "user-1".to_string(); + job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(); + job.request_payload_json = r#"{"prompt":"same"}"#.to_string(); + + assert!( + ensure_editor_generation_job_matches_request( + job.clone(), + "user-1", + EDITOR_IMAGE_GENERATION_JOB_KIND, + r#"{"prompt":"same"}"#, + "幂等冲突", + ) + .is_ok() + ); + let error = ensure_editor_generation_job_matches_request( + job, + "user-1", + EDITOR_IMAGE_GENERATION_JOB_KIND, + r#"{"prompt":"changed"}"#, + "幂等冲突", + ) + .expect_err("same request id must reject a different payload"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + } + + #[test] + fn replayed_legacy_external_job_ignores_only_removed_client_references() { + let mut job = queue_job_fixture("queued", None); + job.owner_user_id = "user-1".to_string(); + job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(); + job.request_payload_json = serde_json::to_string(&json!({ + "prompt": "same", + "generationInputs": { + "fields": [{"title": "提示词", "value": "same"}], + "references": [{ + "title": "旧客户端引用", + "refType": "asset", + "refId": "asset-forged" + }] + } + })) + .expect("legacy payload should serialize"); + let requested = serde_json::to_string(&json!({ + "prompt": "same", + "generationInputs": { + "fields": [{"title": "提示词", "value": "same"}] + } + })) + .expect("current payload should serialize"); + + assert!( + ensure_editor_generation_job_matches_request( + job.clone(), + "user-1", + EDITOR_IMAGE_GENERATION_JOB_KIND, + requested.as_str(), + "幂等冲突", + ) + .is_ok() + ); + + let changed = requested.replace("same", "changed"); + let error = ensure_editor_generation_job_matches_request( + job, + "user-1", + EDITOR_IMAGE_GENERATION_JOB_KIND, + changed.as_str(), + "幂等冲突", + ) + .expect_err("non-reference payload changes must still conflict"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + } + + #[test] + fn replayed_jobs_with_references_on_both_sides_compare_them_strictly() { + let mut job = queue_job_fixture("queued", None); + job.owner_user_id = "user-1".to_string(); + job.job_kind = "editor_video_generation".to_string(); + job.request_payload_json = serde_json::to_string(&json!({ + "prompt": "same", + "generationInputs": { + "references": [{"refType": "asset", "refId": "asset-1"}] + } + })) + .expect("existing video payload should serialize"); + let requested = serde_json::to_string(&json!({ + "prompt": "same", + "generationInputs": { + "references": [{"refType": "asset", "refId": "asset-2"}] + } + })) + .expect("requested video payload should serialize"); + + let error = ensure_editor_generation_job_matches_request( + job, + "user-1", + "editor_video_generation", + requested.as_str(), + "幂等冲突", + ) + .expect_err("different retained references must conflict"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + } + + #[test] + fn replayed_video_job_does_not_use_image_reference_migration_compatibility() { + let mut job = queue_job_fixture("queued", None); + job.owner_user_id = "user-1".to_string(); + job.job_kind = EDITOR_VIDEO_GENERATION_JOB_KIND.to_string(); + job.request_payload_json = serde_json::to_string(&json!({ + "prompt": "same", + "generationInputs": { + "references": [{"refType": "asset", "refId": "asset-1"}] + } + })) + .expect("existing video payload should serialize"); + let requested = serde_json::to_string(&json!({ + "prompt": "same", + "generationInputs": {} + })) + .expect("requested video payload should serialize"); + + let error = ensure_editor_generation_job_matches_request( + job, + "user-1", + EDITOR_VIDEO_GENERATION_JOB_KIND, + requested.as_str(), + "幂等冲突", + ) + .expect_err("video payloads did not migrate away from references"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + } + #[test] fn serialize_payload_accepts_persistable_media_references() { let payload = json!({ diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 1c9426b5b..8e70cac59 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -126,7 +126,19 @@ const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 = EDITOR_ICON_SPRITESHE const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); +const EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT: usize = 5; +const EDITOR_QUICK_EDIT_REFERENCE_LIMIT: usize = 9; +const EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT: usize = 8; +const EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT: usize = 8; const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5; + +fn editor_provider_reference_limit(model: &str) -> usize { + if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + 14 + } else { + 5 + } +} const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; @@ -1351,7 +1363,7 @@ pub async fn create_editor_project_resource( Json(payload): Json, ) -> Result, AppError> { let generation_inputs_json = serialize_editor_asset_metadata( - sanitize_editor_client_generation_inputs(payload.generation_inputs.clone()), + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.clone()), )?; let object_key = normalize_editor_object_key(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; @@ -1520,7 +1532,7 @@ pub async fn create_editor_asset( Json(payload): Json, ) -> Result, AppError> { let generation_inputs_json = serialize_editor_asset_metadata( - sanitize_editor_client_generation_inputs(payload.generation_inputs.clone()), + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.clone()), )?; let object_key = normalize_editor_object_key(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; @@ -1697,7 +1709,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( external_idempotency_key: Option<&str>, ) -> Result { payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-image-generation", @@ -1717,6 +1729,19 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); + let reference_limit = if matches!(normalized_kind, Some("quick-edit")) { + EDITOR_QUICK_EDIT_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model)) + } else { + EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT + }; + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + reference_limit, + "editor-image-generation", + "referenceImageSrcs", + "生成参考图", + )?; let price_mud_points = u64::from( state .editor_generation_pricing() @@ -1810,6 +1835,30 @@ pub(crate) async fn generate_editor_image_for_owner( payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); + let reference_limit = if matches!(normalized_kind, Some("quick-edit")) { + EDITOR_QUICK_EDIT_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model)) + } else { + EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT + }; + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + reference_limit, + "editor-image-generation", + "referenceImageSrcs", + "生成参考图", + )?; + payload.generation_inputs = rebuild_editor_generation_input_references( + state, + caller.owner_user_id.as_str(), + payload.generation_inputs.take(), + build_editor_generation_reference_sources( + None, + payload.reference_image_srcs.as_deref(), + "参考图", + ), + ) + .await?; let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some(); let image_size = resolve_editor_image_request_size( normalized_kind, @@ -1831,18 +1880,12 @@ pub(crate) async fn generate_editor_image_for_owner( Some("publication-material") => "图片画布生成宣发素材", _ => "图片画布生成图片", }; - let reference_limit = if matches!(normalized_kind, Some("quick-edit")) { - 9 - } else { - 5 - }; let reference_sources = payload .reference_image_srcs .unwrap_or_default() .into_iter() .map(|source| source.trim().to_string()) .filter(|source| !source.is_empty()) - .take(reference_limit) .collect::>(); // 决策移入闭包后 payload.reference_image_srcs 已被 reference_sources 消费, // 这里从过滤后的 reference_sources 预先固化 ui-design 的「是否带参考图」布尔。 @@ -4055,7 +4098,7 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner( external_idempotency_key: Option<&str>, ) -> Result { payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), "editor-image-edit", @@ -4075,6 +4118,15 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner( payload.image_size.as_deref(), payload.size.as_deref(), ); + let extra_reference_limit = EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + extra_reference_limit, + "editor-image-edit", + "referenceImageSrcs", + "修改参考图", + )?; let image_size = normalize_editor_image_generation_size(payload.size.as_deref()); let price_mud_points = u64::from( resolve_editor_image_edit_price( @@ -4137,6 +4189,26 @@ pub(crate) async fn edit_editor_image_for_owner( payload.image_size.as_deref(), payload.size.as_deref(), ); + let extra_reference_limit = EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + extra_reference_limit, + "editor-image-edit", + "referenceImageSrcs", + "修改参考图", + )?; + payload.generation_inputs = rebuild_editor_generation_input_references( + state, + caller.owner_user_id.as_str(), + payload.generation_inputs.take(), + build_editor_generation_reference_sources( + Some(("原图", payload.source_image_src.as_str())), + payload.reference_image_srcs.as_deref(), + "参考图", + ), + ) + .await?; let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some(); let requested_image_size = normalize_editor_image_generation_size(payload.size.as_deref()); let delivery_size = if has_dimension_options { @@ -4159,8 +4231,7 @@ pub(crate) async fn edit_editor_image_for_owner( ) .await?, ); - for source in - normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref(), 8) + for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref()) { reference_images.push( parse_editor_reference_image(state, caller.owner_user_id.as_str(), source).await?, @@ -4380,7 +4451,7 @@ pub async fn remove_editor_image_background( Json(mut payload): Json, ) -> Result, AppError> { payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); let caller = EditorGenerationCaller::from_authenticated(&authenticated); ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), @@ -4419,6 +4490,17 @@ pub(crate) async fn remove_editor_image_background_for_owner( ) -> Result, AppError> { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + payload.generation_inputs = rebuild_editor_generation_input_references( + state, + caller.owner_user_id.as_str(), + payload.generation_inputs.take(), + build_editor_generation_reference_sources( + Some(("原图", payload.source_image_src.as_str())), + None, + "参考图", + ), + ) + .await?; let started_at = Instant::now(); caller.report_processing_phase(state).await?; let source = resolve_editor_background_removal_source( @@ -4650,6 +4732,15 @@ fn validate_editor_pixel_art_snap_placeholder_exists( struct EditorPixelArtSourceResolution { object_key: String, asset_kind: Option, + generation_input_reference: Option, + existing_result_generation_inputs: Option>, +} + +fn resolve_editor_pixel_art_persisted_generation_inputs( + authoritative: Option, + existing_result: Option>, +) -> Option { + existing_result.unwrap_or(authoritative) } fn push_editor_pixel_art_source_asset_kind( @@ -4723,7 +4814,17 @@ async fn resolve_editor_pixel_art_source_for_owner( project: &EditorProjectPayload, source_resource: Option<&EditorProjectResourcePayload>, requested_asset_kind: Option<&str>, + expected_result_resource_id: &str, + expected_result_task_id: &str, ) -> Result { + let existing_result_generation_inputs = project + .resources + .iter() + .find(|resource| { + resource.resource_id.trim() == expected_result_resource_id + && resource.task_id.as_deref().map(str::trim) == Some(expected_result_task_id) + }) + .map(|resource| resource.generation_inputs.clone()); let resolved_without_lookup = match source_resource { Some(source_resource) => resolve_editor_pixel_art_source_without_lookup( owner_user_id, @@ -4886,9 +4987,40 @@ async fn resolve_editor_pixel_art_source_for_owner( discovered_asset_kinds.as_slice(), storage_asset_kinds.as_slice(), )?; + let generation_input_reference = if let Some(source_resource) = source_resource { + Some(json!({ + "title": "原图", + "label": source_resource + .label + .as_deref() + .or(source_resource.asset_kind.as_deref()) + .unwrap_or("项目资源"), + "refType": "project-resource", + "refId": source_resource.resource_id, + })) + } else if let Some((projects, library)) = owner_records.as_ref() { + editor_generation_reference_from_records( + projects.as_slice(), + library.assets.as_slice(), + "原图".to_string(), + source, + ) + .or_else(|| { + editor_generation_reference_from_records( + projects.as_slice(), + library.assets.as_slice(), + "原图".to_string(), + object_key.as_str(), + ) + }) + } else { + None + }; Ok(EditorPixelArtSourceResolution { object_key, asset_kind, + generation_input_reference, + existing_result_generation_inputs, }) } @@ -5025,13 +5157,19 @@ pub async fn snap_editor_image_to_pixel_art( payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(mut payload) = parse_editor_generation_json_payload(payload)?; + // 旧版本把客户端 references 纳入完美像素 operation fingerprint。继续用同一份 + // canonical 输入计算指纹,确保升级前响应丢失的请求仍能命中原 operation;真正持久化 + // 的 generationInputs 会在下方删除客户端 references,并按已鉴权源记录重建。 + let fingerprint_generation_inputs = + sanitize_editor_client_generation_inputs(payload.generation_inputs.clone()) + .map(canonicalize_editor_json_value); // 中文注释:`screenColorHex / mattingProvider / mattingModel` 是服务端产出的处理事实 // (背景色决策与 bgfilter 实际执行后写入),不接受客户端声明,否则用户可以给自己的记录 // 伪造抠图模型等审计字段,污染后台按这些字段做的统计与排障。本端点是纯几何规整、不抠图, // 任何 matting 元数据出现在这里本身就是伪造。与其余生成入口共用同一个 sanitizer,位置也 // 保持一致:在任何 IO 之前。 payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); payload.generation_inputs = payload .generation_inputs .take() @@ -5060,6 +5198,13 @@ pub async fn snap_editor_image_to_pixel_art( })) })?; validate_editor_pixel_art_snap_canvas_completion(&payload.canvas_completion)?; + let dialog_id = + normalized_canvas_completion_dialog_id(&payload.canvas_completion).ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "完美像素必须关联有效的画布生成占位。", + ) + })?; // 中文注释:在 CPU 处理和 OSS PUT 前完成所有无副作用校验;主动操作的像素规整 // 失败必须直接返回错误,不能先创建与原图相同的派生资源。 serialize_editor_asset_metadata(payload.generation_inputs.clone())?; @@ -5068,6 +5213,16 @@ pub async fn snap_editor_image_to_pixel_art( // 都在许可覆盖范围内,许可随 handler 返回自动释放。 let _snap_permit = acquire_editor_pixel_art_snap_permit(processing_deadline).await?; let owner_user_id = current_owner_user_id(&authenticated); + let expected_result_task_id = format!("pixel-art-snap-{dialog_id}"); + let expected_result_resource_id = format!( + "{EDITOR_RESOURCE_ID_PREFIX}{}", + editor_pixel_art_stable_record_suffix( + owner_user_id.as_str(), + project_id.as_str(), + dialog_id.as_str(), + "project-resource", + ) + ); // 中文注释:归属校验阶段必须自己套绝对 deadline。预算只是从 handler 入口起算, // 起算不等于覆盖——此前这段里的 SpacetimeDB 调用全是裸 await,第一次真正应用预算 // 是下载。SpacetimeDB 慢时请求会一路走到下载才发现预算早已耗尽,返回的还是下载相关 @@ -5118,6 +5273,8 @@ pub async fn snap_editor_image_to_pixel_art( &project, source_resource, payload.asset_kind.as_deref(), + expected_result_resource_id.as_str(), + expected_result_task_id.as_str(), ) .await }) @@ -5130,6 +5287,18 @@ pub async fn snap_editor_image_to_pixel_art( })??; let source_object_key = source.object_key; let asset_kind = source.asset_kind; + let authoritative_generation_inputs = + rebuild_editor_generation_inputs_with_authoritative_references( + payload.generation_inputs.take(), + source.generation_input_reference.into_iter().collect(), + ); + // 旧结果已经落库时,重放必须携带原记录的 metadata 才能通过 SpacetimeDB 的精确 + // compare-and-return;这只复用已由服务端持久化的 owner-scoped 记录。新操作始终使用 + // 上面按已鉴权源重建的 references,不再接受客户端自报 provenance。 + payload.generation_inputs = resolve_editor_pixel_art_persisted_generation_inputs( + authoritative_generation_inputs, + source.existing_result_generation_inputs, + ); let source_image = download_editor_persisted_image_object_within_deadline( &state, source_object_key.as_str(), @@ -5159,13 +5328,6 @@ pub async fn snap_editor_image_to_pixel_art( })?; let output_image_sha256 = editor_pixel_art_sha256_hex(snapped_image.bytes.as_slice()); - let dialog_id = - normalized_canvas_completion_dialog_id(&payload.canvas_completion).ok_or_else(|| { - editor_pixel_art_snap_failure( - StatusCode::BAD_REQUEST, - "完美像素必须关联有效的画布生成占位。", - ) - })?; let asset_folder_id = normalize_generated_asset_folder_id( resolve_editor_pixel_art_asset_folder_id(payload.asset_folder_id.take()), owner_user_id.as_str(), @@ -5188,7 +5350,7 @@ pub async fn snap_editor_image_to_pixel_art( asset_kind.as_deref(), asset_folder_id.as_str(), asset_label.as_str(), - payload.generation_inputs.as_ref(), + fingerprint_generation_inputs.as_ref(), &payload.canvas_completion, )?; let response_task_id = persistence_identity.task_id.clone(); @@ -6157,7 +6319,7 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( external_idempotency_key: Option<&str>, ) -> Result { payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_source_is_stable( payload.reference_image_src.as_str(), "editor-icon-spritesheet", @@ -6175,6 +6337,15 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); + let extra_reference_limit = EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + extra_reference_limit, + "editor-icon-spritesheet", + "referenceImageSrcs", + "图标素材参考图", + )?; let price_mud_points = u64::from( resolve_editor_icon_spritesheet_price( state, @@ -6221,6 +6392,31 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "referenceImageSrcs", "图标素材参考图", )?; + let generation_options = normalize_editor_generation_options( + payload.model.as_deref(), + payload.aspect_ratio.as_deref(), + payload.image_size.as_deref(), + ); + let extra_reference_limit = EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + extra_reference_limit, + "editor-icon-spritesheet", + "referenceImageSrcs", + "图标素材参考图", + )?; + payload.generation_inputs = rebuild_editor_generation_input_references( + state, + caller.owner_user_id.as_str(), + payload.generation_inputs.take(), + build_editor_generation_reference_sources( + Some(("图标规范", payload.reference_image_src.as_str())), + payload.reference_image_srcs.as_deref(), + "参考图", + ), + ) + .await?; let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; let user_prompt = icon_descriptions.join("\n"); let (image_style, mut generation_warning) = @@ -6247,8 +6443,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( })) })?, ); - for source in - normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref(), 8) + for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref()) { reference_images.push( parse_editor_reference_image(state, caller.owner_user_id.as_str(), source) @@ -6262,11 +6457,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( })?, ); } - let generation_options = normalize_editor_generation_options( - payload.model.as_deref(), - payload.aspect_ratio.as_deref(), - payload.image_size.as_deref(), - ); let expected_price_mud_points = resolve_editor_icon_spritesheet_price( state, Some(generation_options.model), @@ -7307,7 +7497,7 @@ pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner( external_idempotency_key: Option<&str>, ) -> Result { payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), "editor-ui-design-asset-extraction", @@ -7325,6 +7515,15 @@ pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner( payload.aspect_ratio.as_str(), payload.image_size.as_str(), )?; + let extra_reference_limit = EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + extra_reference_limit, + "editor-ui-design-asset-extraction", + "referenceImageSrcs", + "UI素材参考图", + )?; let price_mud_points = u64::from( resolve_editor_ui_design_asset_extraction_price( state, @@ -7371,6 +7570,31 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "referenceImageSrcs", "UI素材参考图", )?; + let generation_options = normalize_editor_ui_design_asset_extraction_options( + payload.model.as_deref(), + payload.aspect_ratio.as_str(), + payload.image_size.as_str(), + )?; + let extra_reference_limit = EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + extra_reference_limit, + "editor-ui-design-asset-extraction", + "referenceImageSrcs", + "UI素材参考图", + )?; + payload.generation_inputs = rebuild_editor_generation_input_references( + state, + caller.owner_user_id.as_str(), + payload.generation_inputs.take(), + build_editor_generation_reference_sources( + Some(("UI设计图", payload.source_image_src.as_str())), + payload.reference_image_srcs.as_deref(), + "参考图", + ), + ) + .await?; // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。 let requested_screen_color = payload.screen_color.clone(); @@ -7394,10 +7618,8 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( })) })?; let mut reference_images = vec![reference_image]; - for source in normalize_editor_reference_image_sources( - payload.reference_image_srcs.as_deref(), - EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT, - ) { + for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref()) + { reference_images.push( parse_editor_reference_image(state, caller.owner_user_id.as_str(), source) .await @@ -7410,11 +7632,6 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( })?, ); } - let generation_options = normalize_editor_ui_design_asset_extraction_options( - payload.model.as_deref(), - payload.aspect_ratio.as_str(), - payload.image_size.as_str(), - )?; let expected_price_mud_points = resolve_editor_ui_design_asset_extraction_price( state, Some(generation_options.model), @@ -8225,6 +8442,145 @@ pub(crate) fn sanitize_editor_client_generation_inputs(value: Option) -> value.map(sanitize_editor_reserved_generation_inputs) } +fn strip_editor_generation_input_references(mut value: Value) -> Value { + if let Some(object) = value.as_object_mut() { + object.remove("references"); + } + value +} + +pub(crate) fn sanitize_editor_untrusted_generation_inputs(value: Option) -> Option { + sanitize_editor_client_generation_inputs(value).map(strip_editor_generation_input_references) +} + +fn editor_generation_reference_from_records( + projects: &[EditorProjectRecord], + assets: &[EditorAssetRecord], + title: String, + source: &str, +) -> Option { + let source = source.trim(); + if source.is_empty() { + return None; + } + + if let Some(resource) = projects + .iter() + .flat_map(|project| project.resources.iter()) + .find(|resource| resource.resource_id.trim() == source) + { + return Some(json!({ + "title": title, + "label": resource.asset_kind.as_deref().unwrap_or("项目资源"), + "refType": "project-resource", + "refId": resource.resource_id, + })); + } + if let Some(asset) = assets.iter().find(|asset| asset.asset_id.trim() == source) { + return Some(json!({ + "title": title, + "label": asset.label, + "refType": "asset", + "refId": asset.asset_id, + })); + } + + let object_key = normalize_editor_reference_object_key(source).ok()?; + if let Some(resource) = projects + .iter() + .flat_map(|project| project.resources.iter()) + .find(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + object_key.as_str(), + ) + }) + { + return Some(json!({ + "title": title, + "label": resource.asset_kind.as_deref().unwrap_or("项目资源"), + "refType": "project-resource", + "refId": resource.resource_id, + })); + } + assets + .iter() + .find(|asset| { + editor_record_object_key_matches( + asset.object_key.as_deref(), + asset.image_src.as_str(), + object_key.as_str(), + ) + }) + .map(|asset| { + json!({ + "title": title, + "label": asset.label, + "refType": "asset", + "refId": asset.asset_id, + }) + }) +} + +async fn rebuild_editor_generation_input_references( + state: &AppState, + owner_user_id: &str, + generation_inputs: Option, + reference_sources: Vec<(String, String)>, +) -> Result, AppError> { + let sanitized = sanitize_editor_client_generation_inputs(generation_inputs); + if sanitized.is_none() && reference_sources.is_empty() { + return Ok(None); + } + + let references = if reference_sources.is_empty() { + Vec::new() + } else { + let projects = state + .spacetime_client() + .list_editor_projects(owner_user_id.to_string()) + .await + .map_err(map_editor_project_error)?; + let library = state + .spacetime_client() + .get_editor_asset_library(owner_user_id.to_string(), current_utc_micros()) + .await + .map_err(map_editor_project_error)?; + reference_sources + .into_iter() + .filter_map(|(title, source)| { + editor_generation_reference_from_records( + projects.as_slice(), + library.assets.as_slice(), + title, + source.as_str(), + ) + }) + .collect() + }; + + Ok(rebuild_editor_generation_inputs_with_authoritative_references(sanitized, references)) +} + +fn rebuild_editor_generation_inputs_with_authoritative_references( + generation_inputs: Option, + references: Vec, +) -> Option { + if generation_inputs.is_none() && references.is_empty() { + return None; + } + let mut value = generation_inputs.unwrap_or_else(|| json!({ "fields": [] })); + if !value.is_object() { + value = json!({ "fields": [] }); + } + value + .as_object_mut() + .expect("generation inputs should be an object") + .insert("references".to_string(), Value::Array(references)); + Some(value) +} + fn sanitize_editor_user_generation_inputs(value: Value) -> Value { sanitize_editor_reserved_generation_inputs(sanitize_editor_payload_inline_media(value)) } @@ -10458,17 +10814,66 @@ fn resolve_editor_background_removal_resource_model( None } -fn normalize_editor_reference_image_sources(sources: Option<&[String]>, limit: usize) -> Vec<&str> { +fn normalize_editor_reference_image_sources(sources: Option<&[String]>) -> Vec<&str> { sources .unwrap_or(&[]) .iter() .map(String::as_str) .map(str::trim) .filter(|source| !source.is_empty()) - .take(limit) .collect() } +fn ensure_editor_reference_image_source_limit( + sources: Option<&[String]>, + limit: usize, + provider: &str, + field: &str, + label: &str, +) -> Result<(), AppError> { + let actual_count = sources + .unwrap_or(&[]) + .iter() + .filter(|source| !source.trim().is_empty()) + .count(); + if actual_count <= limit { + return Ok(()); + } + Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": provider, + "field": field, + "message": format!("{label}最多允许 {limit} 张,当前提交 {actual_count} 张。"), + "actualCount": actual_count, + "maxCount": limit, + })), + ) +} + +fn build_editor_generation_reference_sources( + primary: Option<(&str, &str)>, + extras: Option<&[String]>, + extra_title: &str, +) -> Vec<(String, String)> { + let mut references = Vec::new(); + if let Some((title, source)) = primary { + if !source.trim().is_empty() { + references.push((title.to_string(), source.trim().to_string())); + } + } + references.extend( + extras + .unwrap_or(&[]) + .iter() + .map(String::as_str) + .map(str::trim) + .filter(|source| !source.is_empty()) + .enumerate() + .map(|(index, source)| (format!("{extra_title} {}", index + 1), source.to_string())), + ); + references +} + pub(crate) fn normalize_editor_reference_object_key(source: &str) -> Result { let object_key = source.trim().trim_start_matches('/').to_string(); if object_key.is_empty() || LegacyAssetPrefix::from_object_key(object_key.as_str()).is_none() { @@ -11776,6 +12181,159 @@ mod tests { ); } + #[test] + fn untrusted_generation_inputs_drop_client_claimed_references() { + let sanitized = sanitize_editor_untrusted_generation_inputs(Some(json!({ + "fields": [{"title": "角色设定", "value": "红发骑士"}], + "references": [{ + "title": "伪造引用", + "label": "其他用户素材", + "refType": "asset", + "refId": "asset-forged" + }] + }))) + .expect("visible fields should stay present"); + + assert_eq!( + sanitized["fields"], + json!([{"title": "角色设定", "value": "红发骑士"}]) + ); + assert!(sanitized.get("references").is_none()); + } + + #[test] + fn generation_reference_provenance_comes_from_matching_owner_records() { + let resource = test_editor_project_resource_record( + "resource-1", + "project-1", + "generated-character-drafts/editor/shared.png", + Some("gpt-image-2"), + None, + ); + let projects = vec![test_editor_project_record("project-1", vec![resource])]; + let asset = test_editor_asset_record( + "asset-1", + "generated-character-drafts/editor/asset.png", + Some("gpt-image-2"), + None, + ); + + let resource_reference = editor_generation_reference_from_records( + projects.as_slice(), + std::slice::from_ref(&asset), + "参考图 1".to_string(), + "generated-character-drafts/editor/shared.png", + ) + .expect("owned resource object key should produce provenance"); + assert_eq!(resource_reference["refType"], "project-resource"); + assert_eq!(resource_reference["refId"], "resource-1"); + + let asset_reference = editor_generation_reference_from_records( + projects.as_slice(), + std::slice::from_ref(&asset), + "参考图 2".to_string(), + "asset-1", + ) + .expect("owned asset id should produce provenance"); + assert_eq!(asset_reference["refType"], "asset"); + assert_eq!(asset_reference["refId"], "asset-1"); + + assert!( + editor_generation_reference_from_records( + projects.as_slice(), + std::slice::from_ref(&asset), + "参考图 3".to_string(), + "asset-forged", + ) + .is_none() + ); + } + + #[test] + fn perfect_pixel_replaces_client_references_with_authoritative_source() { + let untrusted = sanitize_editor_untrusted_generation_inputs(Some(json!({ + "fields": [{"title": "处理", "value": "完美像素"}], + "references": [{ + "title": "伪造跨账号引用", + "label": "其他账号素材", + "refType": "asset", + "refId": "asset-other-owner" + }] + }))); + let rebuilt = rebuild_editor_generation_inputs_with_authoritative_references( + untrusted, + vec![json!({ + "title": "原图", + "label": "当前项目资源", + "refType": "project-resource", + "refId": "resource-owned" + })], + ) + .expect("perfect pixel metadata should be rebuilt"); + + assert_eq!( + rebuilt["references"], + json!([{ + "title": "原图", + "label": "当前项目资源", + "refType": "project-resource", + "refId": "resource-owned" + }]) + ); + assert!(!rebuilt.to_string().contains("asset-other-owner")); + } + + #[test] + fn perfect_pixel_replay_uses_existing_server_metadata_for_exact_compare() { + let authoritative = Some(json!({ + "fields": [], + "references": [{"refType": "project-resource", "refId": "resource-owned"}] + })); + let historical = Some(json!({ + "fields": [], + "references": [{"refType": "asset", "refId": "legacy-client-value"}] + })); + + assert_eq!( + resolve_editor_pixel_art_persisted_generation_inputs( + authoritative, + Some(historical.clone()), + ), + historical + ); + assert_eq!( + resolve_editor_pixel_art_persisted_generation_inputs(None, Some(None)), + None + ); + } + + #[test] + fn editor_reference_limit_rejects_overflow_instead_of_truncating() { + let sources = (1..=6) + .map(|index| format!("generated-character-drafts/editor/{index}.png")) + .collect::>(); + let error = ensure_editor_reference_image_source_limit( + Some(sources.as_slice()), + 5, + "editor-image-generation", + "referenceImageSrcs", + "生成参考图", + ) + .expect_err("the sixth reference must be rejected"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + error + .details() + .and_then(|details| details.get("actualCount")), + Some(&json!(6)) + ); + assert_eq!( + error.details().and_then(|details| details.get("maxCount")), + Some(&json!(5)) + ); + } + #[test] fn background_removal_source_model_recovers_normal_ancestor() { let source_key = "generated-character-drafts/editor/result.png"; @@ -15465,7 +16023,7 @@ mod tests { } #[test] - fn editor_reference_image_sources_are_trimmed_and_limited() { + fn editor_reference_image_sources_are_trimmed_without_silent_truncation() { let sources = vec![ " generated-character-drafts/editor/a.png ".to_string(), "".to_string(), @@ -15474,10 +16032,11 @@ mod tests { ]; assert_eq!( - normalize_editor_reference_image_sources(Some(sources.as_slice()), 2), + normalize_editor_reference_image_sources(Some(sources.as_slice())), vec![ "generated-character-drafts/editor/a.png", - "generated-character-drafts/editor/b.png" + "generated-character-drafts/editor/b.png", + "generated-character-drafts/editor/c.png" ] ); } diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 1da75d7b5..4923b2b3f 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -41,7 +41,7 @@ use crate::{ enqueue_editor_image_generation_for_owner, enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, - parse_editor_generation_json_payload, sanitize_editor_client_generation_inputs, + parse_editor_generation_json_payload, sanitize_editor_untrusted_generation_inputs, save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata, }, external_api_auth::ExternalApiPrincipal, @@ -991,7 +991,9 @@ fn normalize_project_title(title: Option) -> String { fn serialize_external_editor_generation_inputs( generation_inputs: Option, ) -> Result, AppError> { - serialize_editor_asset_metadata(sanitize_editor_client_generation_inputs(generation_inputs)) + serialize_editor_asset_metadata(sanitize_editor_untrusted_generation_inputs( + generation_inputs, + )) } #[cfg(test)] @@ -1064,6 +1066,12 @@ mod tests { "screenColorHex": "#00FF00", "mattingProvider": "forged-provider", "mattingModel": "forged-model", + "references": [{ + "title": "伪造引用", + "label": "其他用户素材", + "refType": "asset", + "refId": "asset-forged" + }], "characterAnimation": {"durationSeconds": 4} }))) .expect("外部编辑器生成输入应可序列化") @@ -1075,6 +1083,7 @@ mod tests { assert!(parsed.get("screenColorHex").is_none()); assert!(parsed.get("mattingProvider").is_none()); assert!(parsed.get("mattingModel").is_none()); + assert!(parsed.get("references").is_none()); } #[test] @@ -1300,6 +1309,14 @@ mod tests { .get("default") .is_none() ); + let generation_references = &parsed["components"]["schemas"]["EditorImageGenerationRequest"] + ["properties"]["referenceImageSrcs"]; + assert_eq!(generation_references["maxItems"], 9); + assert!( + generation_references["items"]["description"] + .as_str() + .is_some_and(|description| description.contains("超限返回 400")) + ); assert_eq!( parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"], "array" @@ -1333,6 +1350,21 @@ mod tests { .get("targetLayerId") .is_some() ); + for (schema, max_items) in [ + ("EditorImageEditRequest", 8), + ("EditorIconSpritesheetGenerationRequest", 8), + ("EditorUiDesignAssetExtractionRequest", 5), + ] { + let references = + &parsed["components"]["schemas"][schema]["properties"]["referenceImageSrcs"]; + assert_eq!(references["maxItems"], max_items, "{schema}"); + assert!( + references["items"]["description"] + .as_str() + .is_some_and(|description| description.contains("超限返回 400")), + "{schema}" + ); + } assert!( parsed["paths"] .get("/api/external/v1/editor/icon-spritesheets/generations") diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs index c71b2c8ab..24b10196a 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/vector_engine/client.rs @@ -9,7 +9,10 @@ use super::{ effective_request_timeout_ms, request_budget_exhausted_error, retry_delay_fits_request_deadline, }, - constants::{GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER}, + constants::{ + GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, + VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER, + }, curl_transport::{ map_curl_error, send_vector_engine_json_request_with_curl, send_vector_engine_multipart_edit_request_with_curl, @@ -320,6 +323,15 @@ pub async fn create_vector_engine_nanobanana_generate_content( reference_images: &[ReferenceImage], failure_context: &str, ) -> Result { + if reference_images.len() > VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!( + "{failure_context}:参考图最多允许 {VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。", + reference_images.len() + ), + }); + } let model = normalize_vector_engine_image_model(model); let request_url = vector_engine_nanobanana_generate_content_url(settings, model); let request_body = build_vector_engine_nanobanana_generate_content_request_body( @@ -329,12 +341,9 @@ pub async fn create_vector_engine_nanobanana_generate_content( image_size, reference_images, ); - let reference_image_count = reference_images.iter().take(14).count(); - let reference_image_bytes_total: usize = reference_images - .iter() - .take(14) - .map(|image| image.bytes.len()) - .sum(); + let reference_image_count = reference_images.len(); + let reference_image_bytes_total: usize = + reference_images.iter().map(|image| image.bytes.len()).sum(); let request_params = serde_json::json!({ "model": model, "promptChars": prompt.trim().chars().count(), @@ -536,16 +545,22 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), }); } + if reference_images.len() > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!( + "{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。", + reference_images.len() + ), + }); + } let request_url = vector_engine_images_edit_url(settings); let normalized_size = normalize_image_size_for_model(requested_model, size); - let reference_image_count = reference_images.iter().take(5).count(); - let reference_image_bytes_total: usize = reference_images - .iter() - .take(5) - .map(|image| image.bytes.len()) - .sum(); + let reference_image_count = reference_images.len(); + let reference_image_bytes_total: usize = + reference_images.iter().map(|image| image.bytes.len()).sum(); let started_at = std::time::Instant::now(); let mut upstream_model = preferred_vector_engine_upstream_model(requested_model); let mut recovered_failure_audits = Vec::new(); @@ -1090,6 +1105,63 @@ fn vector_engine_send_retry_jitter_ms() -> u64 { mod tests { use super::*; + fn reference_image(index: usize) -> ReferenceImage { + ReferenceImage { + bytes: vec![index as u8], + mime_type: "image/png".to_string(), + file_name: format!("reference-{index}.png"), + } + } + + fn test_settings() -> VectorEngineImageSettings { + VectorEngineImageSettings { + base_url: "http://127.0.0.1:9".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + request_deadline: None, + } + } + + #[tokio::test] + async fn gpt_image_edit_rejects_six_references_before_network_send() { + let references = (0..6).map(reference_image).collect::>(); + let error = create_vector_engine_image_edit_with_references_and_model( + &reqwest::Client::new(), + &test_settings(), + GPT_IMAGE_2_MODEL, + "测试提示词", + None, + "1024x1024", + 1, + references.as_slice(), + "测试图片编辑失败", + ) + .await + .expect_err("the provider boundary must reject the sixth reference"); + + assert!(matches!(error, PlatformImageError::InvalidRequest { .. })); + } + + #[tokio::test] + async fn nanobanana_rejects_fifteen_references_before_network_send() { + let references = (0..15).map(reference_image).collect::>(); + let error = create_vector_engine_nanobanana_generate_content( + &reqwest::Client::new(), + &test_settings(), + super::super::constants::NANOBANANA_2_MODEL, + "测试提示词", + None, + "1:1", + "1K", + references.as_slice(), + "测试图片生成失败", + ) + .await + .expect_err("the provider boundary must reject the fifteenth reference"); + + assert!(matches!(error, PlatformImageError::InvalidRequest { .. })); + } + #[tokio::test] async fn expired_deadline_stops_generation_before_network_send() { let settings = VectorEngineImageSettings { diff --git a/server-rs/crates/platform-image/src/vector_engine/constants.rs b/server-rs/crates/platform-image/src/vector_engine/constants.rs index 2da30eef3..6480fba73 100644 --- a/server-rs/crates/platform-image/src/vector_engine/constants.rs +++ b/server-rs/crates/platform-image/src/vector_engine/constants.rs @@ -3,3 +3,5 @@ pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c"; pub const NANOBANANA_2_MODEL: &str = "gemini-3.1-flash-image-preview"; pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; +pub const VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES: usize = 5; +pub const VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES: usize = 14; diff --git a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs index 2098718a3..fbe94e1b1 100644 --- a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs +++ b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs @@ -126,7 +126,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( let prompt = prompt.to_string(); let negative_prompt = negative_prompt.map(str::to_string); let normalized_size = normalized_size.to_string(); - let reference_images = reference_images.iter().take(5).cloned().collect::>(); + let reference_images = reference_images.to_vec(); tokio::task::spawn_blocking(move || { send_multipart_edit_request_with_curl_blocking( request_url.as_str(), diff --git a/server-rs/crates/platform-image/src/vector_engine/image_source.rs b/server-rs/crates/platform-image/src/vector_engine/image_source.rs index b9c65e853..dbc0b38a4 100644 --- a/server-rs/crates/platform-image/src/vector_engine/image_source.rs +++ b/server-rs/crates/platform-image/src/vector_engine/image_source.rs @@ -4,7 +4,7 @@ use std::time::Instant; use super::{ budget::request_budget_exhausted_error, - constants::VECTOR_ENGINE_PROVIDER, + constants::{VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER}, error::PlatformImageError, types::{DownloadedImage, GeneratedImages, ReferenceImage}, }; @@ -129,8 +129,20 @@ pub(crate) async fn resolve_reference_images( failure_context: &str, request_deadline: Option, ) -> Result, PlatformImageError> { + let reference_count = reference_images + .iter() + .filter(|source| !source.trim().is_empty()) + .count(); + if reference_count > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!( + "{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {reference_count} 张。" + ), + }); + } let mut resolved = Vec::new(); - for (index, source) in reference_images.iter().take(5).enumerate() { + for (index, source) in reference_images.iter().enumerate() { let source = source.trim(); if source.is_empty() { continue; diff --git a/server-rs/crates/platform-image/src/vector_engine/request.rs b/server-rs/crates/platform-image/src/vector_engine/request.rs index a0daa53c4..af232dbc8 100644 --- a/server-rs/crates/platform-image/src/vector_engine/request.rs +++ b/server-rs/crates/platform-image/src/vector_engine/request.rs @@ -56,7 +56,7 @@ pub fn build_vector_engine_nanobanana_generate_content_request_body( ) -> Value { let prompt = build_prompt_with_negative(prompt, negative_prompt); let mut parts = vec![json!({ "text": prompt })]; - for reference_image in reference_images.iter().take(14) { + for reference_image in reference_images { parts.push(json!({ "inline_data": { "mime_type": reference_image.mime_type, @@ -287,7 +287,6 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params( .filter(|value| !value.is_empty()); let references: Vec = reference_images .iter() - .take(5) .enumerate() .map(|(index, image)| { json!({ @@ -299,11 +298,8 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params( }) }) .collect(); - let reference_image_bytes_total: usize = reference_images - .iter() - .take(5) - .map(|image| image.bytes.len()) - .sum(); + let reference_image_bytes_total: usize = + reference_images.iter().map(|image| image.bytes.len()).sum(); json!({ "model": model, diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index 36a413994..7fc76170a 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -54,6 +54,7 @@ type ImageCanvasBasicGenerationComposerViewProps = { onPickReferenceFromCanvas?: () => void; onToggleReferenceMenu?: () => void; onRememberImageModel?: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: (dialog: GenerateDialogState) => void; dialogLabel?: string; includeDimensions?: boolean; @@ -107,6 +108,7 @@ export function ImageCanvasBasicGenerationComposerView({ onPickReferenceFromCanvas, onToggleReferenceMenu, onRememberImageModel = () => {}, + hasPendingImageReferenceUploads = false, onSubmit, dialogLabel, includeDimensions = true, @@ -271,6 +273,7 @@ export function ImageCanvasBasicGenerationComposerView({ includeDimensions={shouldIncludeDimensions} includeModel={includeModel} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix} dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix} optionLabelPrefix={resolvedOptionLabelPrefix} diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx index 186ccd838..73519d2d1 100644 --- a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx @@ -44,6 +44,7 @@ type ImageCanvasCharacterGenerationComposerViewProps = { onOpenSpecDialog: (specType: SpecGenerationType) => void; onRequestUpload: (target: UploadTarget) => void; onRememberImageModel: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: (dialog: GenerateDialogState) => void; }; @@ -97,6 +98,7 @@ export function ImageCanvasCharacterGenerationComposerView({ onOpenSpecDialog, onRequestUpload, onRememberImageModel, + hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasCharacterGenerationComposerViewProps) { useImageCanvasFloatingOptionDismiss({ @@ -289,6 +291,7 @@ export function ImageCanvasCharacterGenerationComposerView({ setGenerateDialog={setGenerateDialog} includeDimensions onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} optionLabelPrefix="生成图片" cost={calculateEditorImageGenerationPrice({ kind: 'character', diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 51294c71a..316c8862e 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -390,6 +390,7 @@ export type CanvasContextMenuState = }; export type QuickEditPanelState = { + referenceUploadContextId?: string; mode?: 'quick-edit' | 'redraw'; sourceLayerId: string; prompt: string; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 50d05ee39..8b6032918 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -58,12 +58,17 @@ import type { ImageContextMenuState, QuickEditPanelState, SidebarPanel, + UploadTarget, } from './ImageCanvasEditorTypes'; import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel'; import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel'; import { applyEditorGenerationPricingConfig, isCanvasGenerationDialog, + QUICK_EDIT_REFERENCE_LIMIT, + resolveDialogExtraImageReferenceLimit, + resolveExtraImageReferenceLimit, + UI_EXTRA_REFERENCE_LIMIT, } from './ImageCanvasGenerationModel'; import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel'; import { fitViewportToBounds } from './ImageCanvasInteractionModel'; @@ -97,7 +102,10 @@ import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasStageController } from './useImageCanvasStageController'; import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions'; -import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow'; +import { + type ImageReferenceUploadCapacity, + useImageCanvasUploadWorkflow, +} from './useImageCanvasUploadWorkflow'; import { DEFAULT_IMAGE_CANVAS_VIEWPORT, useImageCanvasViewportControls, @@ -430,6 +438,9 @@ export function ImageCanvasEditorView({ const appendUiAssetExtractionReferencesRef = useRef< (references: CharacterReferenceImage[]) => void >(() => {}); + const resolveImageReferenceUploadCapacityRef = useRef< + (target: UploadTarget) => ImageReferenceUploadCapacity | null + >(() => null); const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>( () => {}, ); @@ -672,6 +683,9 @@ export function ImageCanvasEditorView({ discardCanvasHistoryEntriesContainingLayerRef.current(matchesLayer), [], ); + const canDeleteLayersRef = useRef<(targetLayerIds: string[]) => boolean>( + () => true, + ); const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({ layers, setLayers, @@ -679,6 +693,8 @@ export function ImageCanvasEditorView({ setSelectedLayerIds, onDeleteLayerSideEffects: (layerId) => clearDeletedLayerGenerationStateRef.current(layerId), + canDeleteLayers: (targetLayerIds) => + canDeleteLayersRef.current(targetLayerIds), discardHistoryEntriesContainingLayer: discardAssetRelatedCanvasHistory, }); const { @@ -734,6 +750,14 @@ export function ImageCanvasEditorView({ onDeleteAssets: removeCanvasLayersLinkedToAssets, }); + const pendingImageReferenceUploadCountRef = useRef(0); + const isPendingReferenceUploadContextMutationLocked = useCallback( + () => pendingImageReferenceUploadCountRef.current > 0, + [], + ); + const rejectPendingReferenceUploadContextMutation = useCallback(() => { + window.alert('参考图正在上传,请等待上传完成后再切换或关闭生成面板'); + }, []); const handleActivateCanvasGenerationDialog = useCallback(() => { closeGenerationTransientStateRef.current(); setSelectedLayerId(null); @@ -761,6 +785,8 @@ export function ImageCanvasEditorView({ getGeneratingDialogPlaceholder, } = useCanvasGenerationDialogs({ onActivate: handleActivateCanvasGenerationDialog, + isContextMutationLocked: isPendingReferenceUploadContextMutationLocked, + onContextMutationRejected: rejectPendingReferenceUploadContextMutation, }); canvasGenerationDialogsRef.current = canvasGenerationDialogs; const canvasHistoryRefs = useMemo( @@ -1238,7 +1264,7 @@ export function ImageCanvasEditorView({ captureCanvasHistory(action); applyProjectSnapshot(project); if (action.type !== 'perfect-pixel') { - void refreshAssetLibrary(); + void refreshAssetLibrary(); } }, [applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary], @@ -1344,6 +1370,7 @@ export function ImageCanvasEditorView({ const { uploadInputRef, uploadTarget, + pendingImageReferenceUploadCount, requestUpload, handleUploadInputChange, addUploadedFiles, @@ -1365,12 +1392,17 @@ export function ImageCanvasEditorView({ setLayers, setGenerateDialog, setQuickEditPanel: (updater) => setQuickEditPanelRef.current(updater), + resolveImageReferenceUploadCapacity: (target) => + resolveImageReferenceUploadCapacityRef.current(target), appendUiAssetExtractionReferences: (references) => appendUiAssetExtractionReferencesRef.current(references), appendCanvasLayersWithResources, captureCanvasHistory, selectSingleLayer, + refreshAssetLibrary, }); + pendingImageReferenceUploadCountRef.current = + pendingImageReferenceUploadCount; const generationSurface = useImageCanvasGenerationSurface({ layers, canvasSize, @@ -1387,6 +1419,7 @@ export function ImageCanvasEditorView({ generationReferenceButtonRef, publicationReferenceButtonRef, iconSpecButtonRef, + hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0, generateDialog, setGenerateDialog, activeCanvasGenerationDialog, @@ -1549,6 +1582,7 @@ export function ImageCanvasEditorView({ clearDeletedLayerGenerationState, uiAssetExtractionState, uiAssetExtractionSourceLayer, + quickEditPanel, quickEditSelectionState, quickEditSelectionSourceLayer, changeUiAssetExtractionTool, @@ -1593,6 +1627,137 @@ export function ImageCanvasEditorView({ clearDeletedLayerGenerationState; appendUiAssetExtractionReferencesRef.current = appendUiAssetExtractionReferences; + resolveImageReferenceUploadCapacityRef.current = (target) => { + const imageReferenceCount = (references: CharacterReferenceImage[] = []) => + references.filter( + (reference) => (reference.mediaType ?? 'image') === 'image', + ).length; + const generateDialogContextId = generateDialog + ? (generateDialog.id ?? + `${generateDialog.mode}:${generateDialog.sourceLayerId ?? 'draft'}`) + : undefined; + if (target === 'spec-reference') { + return generateDialog?.mode === 'spec' + ? { + label: '规范参考图', + currentCount: 0, + limit: 1, + contextId: generateDialogContextId, + } + : null; + } + if (target === 'character-spec') { + if (generateDialog?.mode !== 'character') { + return null; + } + const referencesFit = + imageReferenceCount(generateDialog.characterReferences) <= + resolveDialogExtraImageReferenceLimit(generateDialog); + return { + label: '角色主图', + currentCount: 0, + limit: referencesFit ? 1 : 0, + contextId: generateDialogContextId, + }; + } + if (target === 'icon-spec') { + if (generateDialog?.mode !== 'icon') { + return null; + } + const referencesFit = + imageReferenceCount(generateDialog.generationReferences) <= + resolveDialogExtraImageReferenceLimit(generateDialog); + return { + label: '图标规范图', + currentCount: 0, + limit: referencesFit ? 1 : 0, + contextId: generateDialogContextId, + }; + } + if (target === 'ui-design-icon-spec') { + if (generateDialog?.mode !== 'ui-design') { + return null; + } + const referencesFit = + imageReferenceCount(generateDialog.generationReferences) <= + resolveDialogExtraImageReferenceLimit(generateDialog); + return { + label: 'UI设计规范图', + currentCount: 0, + limit: referencesFit ? 1 : 0, + contextId: generateDialogContextId, + }; + } + if (target === 'character-reference') { + return generateDialog?.mode === 'character' + ? { + label: '角色参考图', + currentCount: imageReferenceCount( + generateDialog.characterReferences, + ), + limit: resolveDialogExtraImageReferenceLimit(generateDialog), + contextId: generateDialogContextId, + } + : null; + } + if (target === 'generation-reference') { + return generateDialog && generateDialog.mode !== 'video' + ? { + label: '生成参考图', + currentCount: imageReferenceCount( + generateDialog.generationReferences, + ), + limit: resolveDialogExtraImageReferenceLimit(generateDialog), + contextId: generateDialogContextId, + } + : null; + } + if (target === 'publication-reference') { + return generateDialog?.mode === 'publication' + ? { + label: '宣发参考图', + currentCount: imageReferenceCount( + generateDialog.publicationReferences, + ), + limit: resolveDialogExtraImageReferenceLimit(generateDialog), + contextId: generateDialogContextId, + } + : null; + } + if (target === 'quick-edit-reference') { + return quickEditPanel && quickEditPanel.mode !== 'redraw' + ? { + label: '快速编辑参考图', + currentCount: imageReferenceCount( + quickEditPanel.quickEditReferences, + ), + limit: resolveExtraImageReferenceLimit( + quickEditPanel.model, + QUICK_EDIT_REFERENCE_LIMIT, + 1, + ), + contextId: quickEditPanel.referenceUploadContextId, + } + : null; + } + if (target === 'ui-asset-extraction-reference') { + return uiAssetExtractionState + ? { + label: 'UI素材参考图', + currentCount: imageReferenceCount( + uiAssetExtractionState.references, + ), + limit: resolveExtraImageReferenceLimit( + uiAssetExtractionState.model, + UI_EXTRA_REFERENCE_LIMIT, + 1, + ), + contextId: uiAssetExtractionState.referenceUploadContextId, + } + : null; + } + return null; + }; useEffect(() => { if (!isProjectReady || startupIntentConsumedRef.current) { return; @@ -1735,6 +1900,33 @@ export function ImageCanvasEditorView({ contextMenu?.kind === 'layer' ? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null) : null; + const canDeleteLayersDuringReferenceUpload = useCallback( + (targetLayerIds: string[]) => { + if (pendingImageReferenceUploadCountRef.current <= 0) { + return true; + } + const protectedSourceLayerIds = [ + generateDialog?.sourceLayerId, + generationSurface.quickEditPanel?.sourceLayerId, + uiAssetExtractionState?.sourceLayerId, + ].filter((layerId): layerId is string => Boolean(layerId)); + if ( + !targetLayerIds.some((layerId) => + protectedSourceLayerIds.includes(layerId), + ) + ) { + return true; + } + window.alert('参考图正在上传,请等待上传完成后再删除当前生成源图'); + return false; + }, + [ + generateDialog?.sourceLayerId, + generationSurface.quickEditPanel?.sourceLayerId, + uiAssetExtractionState?.sourceLayerId, + ], + ); + canDeleteLayersRef.current = canDeleteLayersDuringReferenceUpload; const { canvasClipboard, canCopyContextLayers, @@ -1779,6 +1971,8 @@ export function ImageCanvasEditorView({ onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog, exportLayerImage, onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning, + canDeleteLayers: (targetLayerIds) => + canDeleteLayersRef.current(targetLayerIds), }); const { canvasMarquee, @@ -2328,6 +2522,7 @@ export function ImageCanvasEditorView({ : uiAssetExtractionSourceLayer, quickEditSelectionState, quickEditSelectionSourceLayer, + hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0, generationComposerStyle, selectedToolbarStyle, perfectPixelLayerIds, diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 3567b1c73..2bb7cfa05 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -81,6 +81,7 @@ type ImageCanvasGenerationComposerViewProps = { isPickingCharacterReferenceFromCanvas: boolean; isPickingIconSpecFromCanvas: boolean; isPickingUiDesignSpecFromCanvas: boolean; + hasPendingImageReferenceUploads?: boolean; generateDialog: GenerateDialogState | null; generationComposerStyle: CSSProperties | null; iconComposerStyle: CSSProperties | null; @@ -994,6 +995,7 @@ export function ImageCanvasGenerationComposerView({ setIsPickingCharacterReferenceFromCanvas, setIsPickingIconSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas, + hasPendingImageReferenceUploads = false, onOpenSpecDialog, onRequestUpload, onSubmitImageGeneration, @@ -1073,6 +1075,7 @@ export function ImageCanvasGenerationComposerView({ setIsGenerationReferenceMenuOpen((open) => !open) } onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1095,6 +1098,7 @@ export function ImageCanvasGenerationComposerView({ onOpenSpecDialog={onOpenSpecDialog} onUpdateSpecFormValue={onUpdateSpecFormValue} onRequestUpload={onRequestUpload} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1119,6 +1123,7 @@ export function ImageCanvasGenerationComposerView({ buildPortalMenuStyle={buildPortalMenuStyle} onRequestUpload={onRequestUpload} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1142,6 +1147,7 @@ export function ImageCanvasGenerationComposerView({ onUpdateSpecFormValue={onUpdateSpecFormValue} onRequestUpload={onRequestUpload} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1202,6 +1208,7 @@ export function ImageCanvasGenerationComposerView({ onOpenSpecDialog={onOpenSpecDialog} onRequestUpload={onRequestUpload} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1223,6 +1230,7 @@ export function ImageCanvasGenerationComposerView({ onRequestUpload={onRequestUpload} onUpdateIconDescriptionText={onUpdateIconDescriptionText} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitIconSpritesheetGeneration} /> ) : null} @@ -1278,6 +1286,7 @@ export function ImageCanvasGenerationComposerView({ buildPortalMenuStyle={buildPortalMenuStyle} onRequestUpload={onRequestUpload} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} setQuickEditPanel={setQuickEditPanel} onSubmit={onSubmitQuickEdit} /> diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index c568371b7..fff458ef8 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -1134,6 +1134,93 @@ describe('ImageCanvasGenerationDialogModel', () => { }); }); + it('caps image references before a sixth reference can enter submission state', () => { + const existingReferences = Array.from({ length: 5 }, (_, index) => ({ + id: `reference-${index}`, + label: `参考图${index + 1}`, + src: `generated-character-drafts/editor/reference-${index}.png`, + })); + const imageDialog: GenerateDialogState = { + mode: 'generate', + prompt: '', + status: 'idle', + generationReferences: existingReferences, + }; + expect( + appendGenerationReference( + imageDialog, + createLayer({ id: 'reference-6', title: '参考图6' }), + ), + ).toMatchObject({ + generationReferences: existingReferences, + }); + + const characterDialog: GenerateDialogState = { + mode: 'character', + prompt: '', + status: 'idle', + characterSpecReference: existingReferences[0], + characterReferences: existingReferences.slice(1), + }; + expect( + appendCharacterReference( + characterDialog, + createLayer({ id: 'character-reference-6', title: '角色参考图5' }), + ), + ).toMatchObject({ + characterReferences: existingReferences.slice(1), + }); + + const iconDialog: GenerateDialogState = { + mode: 'icon', + prompt: '', + status: 'idle', + imageModel: IMAGE_MODEL_GPT_IMAGE_2, + generationReferences: existingReferences.slice(0, 4), + }; + expect( + appendGenerationReference( + iconDialog, + createLayer({ id: 'icon-reference-5', title: '图标参考图5' }), + ), + ).toMatchObject({ + generationReferences: existingReferences.slice(0, 4), + }); + }); + + it('reserves the primary-image slot before character and UI specs are selected', () => { + const references = Array.from({ length: 5 }, (_, index) => ({ + id: `reference-${index}`, + label: `参考图${index + 1}`, + src: `/reference-${index}.png`, + })); + const characterDialog: GenerateDialogState = { + mode: 'character', + prompt: '', + status: 'idle', + characterReferences: references.slice(0, 4), + }; + const uiDialog: GenerateDialogState = { + mode: 'ui-design', + prompt: '', + status: 'idle', + generationReferences: references.slice(0, 4), + }; + + expect( + appendCharacterReference( + characterDialog, + createLayer({ id: 'character-reference-5' }), + ), + ).toMatchObject({ characterReferences: references.slice(0, 4) }); + expect( + appendGenerationReference( + uiDialog, + createLayer({ id: 'ui-reference-5' }), + ), + ).toMatchObject({ generationReferences: references.slice(0, 4) }); + }); + it('updates failed spec and icon dialog fields back to idle state', () => { const specDialog: GenerateDialogState = { mode: 'spec', diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 6c8019b3b..27b6e051e 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -11,6 +11,7 @@ import type { SpecGenerationType, } from './ImageCanvasEditorTypes'; import { + appendLimitedImageReferences, appendLimitedQuickEditReferences, AUDIO_FRAME_DISPLAY_SIZE, AUDIO_FRAME_ORIGINAL_SIZE, @@ -36,6 +37,7 @@ import { normalizeEditorImageModel, PUBLICATION_FRAME_ORIGINAL_SIZE, resizeGenerationPlaceholderToImageSelection, + resolveDialogExtraImageReferenceLimit, resolveEditorImageGenerationPixelSize, resolveEditorVideoGenerationPixelSize, SPEC_FRAME_ORIGINAL_SIZE, @@ -644,10 +646,7 @@ function resolveGeneratedSourceDialogMode({ if (sourceLayer.assetKind === 'character-animation') { return 'character-animation'; } - if ( - sourceLayer.assetKind === 'video' || - sourceLayer.mediaType === 'video' - ) { + if (sourceLayer.assetKind === 'video' || sourceLayer.mediaType === 'video') { return 'video'; } if (sourceLayer.assetKind === 'sound-effect') { @@ -1005,8 +1004,7 @@ export function createSameSourceGenerationDialogDraft({ videoModel: sourceDialog?.videoModel ?? draft.videoModel, videoAspectRatio: sourceDialog?.videoAspectRatio ?? draft.videoAspectRatio, - videoResolution: - sourceDialog?.videoResolution ?? draft.videoResolution, + videoResolution: sourceDialog?.videoResolution ?? draft.videoResolution, videoDurationSeconds: sourceDialog?.videoDurationSeconds ?? draft.videoDurationSeconds, videoMode: sourceDialog?.videoMode ?? draft.videoMode, @@ -1424,6 +1422,7 @@ export function assignCharacterSpecReference( ? { ...resetFailedGenerationDialog(dialog), characterSpecReference: createCanvasLayerReference(layer), + characterReferences: dialog.characterReferences, composerOpen: true, } : dialog; @@ -1437,10 +1436,11 @@ export function appendCharacterReference( getReferenceMediaType(layer) === 'image' ? { ...resetFailedGenerationDialog(dialog), - characterReferences: [ - ...(dialog.characterReferences ?? []), - createCanvasLayerReference(layer), - ], + characterReferences: appendLimitedImageReferences( + dialog.characterReferences, + [createCanvasLayerReference(layer)], + resolveDialogExtraImageReferenceLimit(dialog), + ), composerOpen: true, } : dialog; @@ -1474,10 +1474,11 @@ export function appendGenerationReference( } return { ...resetFailedGenerationDialog(dialog), - generationReferences: [ - ...(dialog.generationReferences ?? []), - createCanvasLayerReference(layer), - ], + generationReferences: appendLimitedImageReferences( + dialog.generationReferences, + [createCanvasLayerReference(layer)], + resolveDialogExtraImageReferenceLimit(dialog), + ), composerOpen: true, }; } @@ -1501,10 +1502,11 @@ export function appendPublicationReference( getReferenceMediaType(layer) === 'image' ? { ...resetFailedGenerationDialog(dialog), - publicationReferences: [ - ...(dialog.publicationReferences ?? []), - createCanvasLayerReference(layer), - ], + publicationReferences: appendLimitedImageReferences( + dialog.publicationReferences, + [createCanvasLayerReference(layer)], + resolveDialogExtraImageReferenceLimit(dialog), + ), composerOpen: true, } : dialog; @@ -1521,6 +1523,11 @@ export function assignIconSpecReference( ? { ...resetFailedGenerationDialog(dialog), iconSpecReference: createCanvasLayerReference(layer), + generationReferences: appendLimitedImageReferences( + [], + dialog.generationReferences ?? [], + resolveDialogExtraImageReferenceLimit(dialog), + ), composerOpen: true, } : dialog; @@ -1537,6 +1544,7 @@ export function assignUiDesignSpecReference( ? { ...resetFailedGenerationDialog(dialog), uiDesignSpecReference: createCanvasLayerReference(layer), + generationReferences: dialog.generationReferences, composerOpen: true, } : dialog; diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx index d00774068..f12e82f76 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx @@ -15,13 +15,13 @@ import { function ImageOptionsHarness({ initialDialog, includeDimensions = true, - + hasPendingImageReferenceUploads = false, onRememberImageModel = vi.fn(), lockedModel, }: { initialDialog: GenerateDialogState; includeDimensions?: boolean; - + hasPendingImageReferenceUploads?: boolean; onRememberImageModel?: (model: string) => void; lockedModel?: string; }) { @@ -35,6 +35,7 @@ function ImageOptionsHarness({ dialog={dialog} setGenerateDialog={setDialog} includeDimensions={includeDimensions} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onRememberImageModel={onRememberImageModel} lockedModel={lockedModel} cost={calculateEditorImageGenerationPrice({ @@ -50,6 +51,13 @@ function ImageOptionsHarness({ {dialog.style ?? '-'} {dialog.status} {dialog.errorMessage ?? '-'} + + {dialog.mode === 'character' + ? (dialog.characterReferences?.length ?? 0) + : dialog.mode === 'publication' + ? (dialog.publicationReferences?.length ?? 0) + : (dialog.generationReferences?.length ?? 0)} + {dialog.placeholder ? [ @@ -122,9 +130,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => { />, ); - expect( - screen.queryByRole('checkbox', { name: '像素艺术' }), - ).toBeNull(); + expect(screen.queryByRole('checkbox', { name: '像素艺术' })).toBeNull(); }, ); @@ -267,6 +273,90 @@ describe('ImageCanvasGenerationImageOptionsView', () => { expect(rememberImageModel).not.toHaveBeenCalled(); }); + it('rejects a model switch that would discard existing references', () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + const rememberImageModel = vi.fn(); + render( + ({ + id: `reference-${index + 1}`, + label: `参考图${index + 1}`, + src: `/reference-${index + 1}.png`, + })), + }} + includeDimensions={false} + onRememberImageModel={rememberImageModel} + />, + ); + + expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8'); + fireEvent.click( + screen.getByRole('button', { name: '生成图片模型 nanobanana2' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole( + 'button', + { name: 'gpt-image-2' }, + ), + ); + + expect(alertMock).toHaveBeenCalledWith( + '当前已有 8 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换', + ); + expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8'); + expect(screen.getByLabelText('当前模型').textContent).toBe( + IMAGE_MODEL_NANOBANANA2, + ); + expect(rememberImageModel).not.toHaveBeenCalled(); + }); + + it('rejects model switching while reference uploads are pending', () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + const rememberImageModel = vi.fn(); + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: '生成图片模型 nanobanana2' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole( + 'button', + { name: 'gpt-image-2' }, + ), + ); + + expect(alertMock).toHaveBeenCalledWith( + '参考图正在上传,请等待上传完成后再切换模型', + ); + expect(screen.getByLabelText('当前模型').textContent).toBe( + IMAGE_MODEL_NANOBANANA2, + ); + expect( + ( + screen.getByRole('button', { + name: '生成角色形象', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect(rememberImageModel).not.toHaveBeenCalled(); + }); + it('renders option menus and shows mud point text in the submit button', () => { render( >; includeDimensions: boolean; includeModel?: boolean; + hasPendingImageReferenceUploads?: boolean; onRememberImageModel: (model: string) => void; optionLabelPrefix?: string; dimensionRatioAriaLabelPrefix?: string; @@ -121,6 +124,7 @@ export function ImageCanvasGenerationImageOptionsView({ setGenerateDialog, includeDimensions, includeModel = true, + hasPendingImageReferenceUploads = false, onRememberImageModel, optionLabelPrefix, dimensionRatioAriaLabelPrefix, @@ -192,7 +196,18 @@ export function ImageCanvasGenerationImageOptionsView({ }; const updateImageModel = (model: string) => { - onRememberImageModel(model); + if (hasPendingImageReferenceUploads) { + window.alert('参考图正在上传,请等待上传完成后再切换模型'); + return; + } + const referenceLimit = resolveDialogExtraImageReferenceLimit(dialog, model); + const referenceCount = countDialogExtraImageReferences(dialog); + if (referenceCount > referenceLimit) { + window.alert( + `当前已有 ${referenceCount} 张参考图,${getEditorImageModelDisplayName(model)} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`, + ); + return; + } setGenerateDialog((currentDialog) => { if (!currentDialog || currentDialog.mode !== dialog.mode) { return currentDialog; @@ -224,6 +239,7 @@ export function ImageCanvasGenerationImageOptionsView({ }; return resizeGenerationPlaceholderToImageSelection(nextDialog); }); + onRememberImageModel(model); }; const togglePanel = (panel: Exclude) => { @@ -408,7 +424,7 @@ export function ImageCanvasGenerationImageOptionsView({ size="xs" shape="pill" className={submitButtonClassName} - disabled={isGenerating} + disabled={isGenerating || hasPendingImageReferenceUploads} aria-label={submitAriaLabel} > {isGenerating ? ( diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 396d10029..a4c4822a7 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -416,6 +416,79 @@ export const SEEDANCE_VIDEO_REFERENCE_LIMITS = { audio: 3, } as const; export const QUICK_EDIT_REFERENCE_LIMIT = 8; +export const IMAGE_GENERATION_REFERENCE_LIMIT = 5; +export const ICON_EXTRA_REFERENCE_LIMIT = 8; +export const UI_EXTRA_REFERENCE_LIMIT = 5; + +export function resolveImageProviderReferenceLimit( + model: string | null | undefined, +) { + return normalizeEditorImageModel(model) === IMAGE_MODEL_NANOBANANA2 ? 14 : 5; +} + +export function resolveExtraImageReferenceLimit( + model: string | null | undefined, + productLimit: number, + primaryReferenceCount = 0, +) { + return Math.max( + 0, + Math.min( + productLimit, + resolveImageProviderReferenceLimit(model) - primaryReferenceCount, + ), + ); +} + +export function resolveDialogExtraImageReferenceLimit( + dialog: GenerateDialogState, + model: string | null | undefined = dialog.imageModel, +) { + if (dialog.mode === 'icon') { + return resolveExtraImageReferenceLimit( + model, + ICON_EXTRA_REFERENCE_LIMIT, + 1, + ); + } + if (dialog.mode === 'ui-design') { + return resolveExtraImageReferenceLimit( + model, + IMAGE_GENERATION_REFERENCE_LIMIT - 1, + 1, + ); + } + if (dialog.mode === 'quick-edit') { + return resolveExtraImageReferenceLimit( + model, + QUICK_EDIT_REFERENCE_LIMIT, + 1, + ); + } + if (dialog.mode === 'character') { + return resolveExtraImageReferenceLimit( + model, + IMAGE_GENERATION_REFERENCE_LIMIT - 1, + 1, + ); + } + return IMAGE_GENERATION_REFERENCE_LIMIT; +} + +export function countDialogExtraImageReferences(dialog: GenerateDialogState) { + const references = + dialog.mode === 'character' + ? dialog.characterReferences + : dialog.mode === 'publication' + ? dialog.publicationReferences + : dialog.mode === 'video' + ? [] + : dialog.generationReferences; + return (references ?? []).filter( + (reference) => (reference.mediaType ?? 'image') === 'image', + ).length; +} + export const CHARACTER_ANIMATION_ACTION_PROMPTS = [ { label: '待机', text: '待机动作,轻微呼吸起伏。' }, { label: '行走', text: '循环行走动作,步伐稳定。' }, @@ -1004,6 +1077,20 @@ export function appendLimitedQuickEditReferences( ); } +export function appendLimitedImageReferences( + references: CharacterReferenceImage[] | undefined, + nextReferences: CharacterReferenceImage[], + limit: number, +) { + const imageReferences = nextReferences.filter( + (reference) => (reference.mediaType ?? 'image') === 'image', + ); + return [...(references ?? []), ...imageReferences].slice( + 0, + Math.max(0, limit), + ); +} + export function createGenerationInputField( title: string, value: string | null | undefined, @@ -1298,17 +1385,17 @@ export function isCanvasGenerationDialog( ): dialog is CanvasGenerationDialogState { return Boolean( dialog?.id && - (dialog.mode === 'generate' || - dialog.mode === 'spec' || - dialog.mode === 'character' || - dialog.mode === 'icon' || - dialog.mode === 'publication' || - dialog.mode === 'ui-design' || - dialog.mode === 'quick-edit' || - dialog.mode === 'character-animation' || - dialog.mode === 'video' || - dialog.mode === 'audio-sound-effect' || - dialog.mode === 'audio-background-music'), + (dialog.mode === 'generate' || + dialog.mode === 'spec' || + dialog.mode === 'character' || + dialog.mode === 'icon' || + dialog.mode === 'publication' || + dialog.mode === 'ui-design' || + dialog.mode === 'quick-edit' || + dialog.mode === 'character-animation' || + dialog.mode === 'video' || + dialog.mode === 'audio-sound-effect' || + dialog.mode === 'audio-background-music'), ); } diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx index e8acd1417..2e55408fe 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx @@ -19,9 +19,7 @@ import type { UploadTarget, } from './ImageCanvasEditorTypes'; import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; -import { - calculateEditorIconSpritesheetPrice, -} from './ImageCanvasGenerationModel'; +import { calculateEditorIconSpritesheetPrice } from './ImageCanvasGenerationModel'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss'; @@ -42,6 +40,7 @@ type ImageCanvasIconSpritesheetComposerViewProps = { onRequestUpload: (target: UploadTarget) => void; onUpdateIconDescriptionText: (value: string) => void; onRememberImageModel: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: (dialog: GenerateDialogState) => void; }; @@ -59,6 +58,7 @@ export function ImageCanvasIconSpritesheetComposerView({ onRequestUpload, onUpdateIconDescriptionText, onRememberImageModel, + hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasIconSpritesheetComposerViewProps) { const descriptionText = @@ -236,6 +236,7 @@ export function ImageCanvasIconSpritesheetComposerView({ setGenerateDialog={setGenerateDialog} includeDimensions onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} optionLabelPrefix="生成图片" cost={calculateEditorIconSpritesheetPrice( dialog.imageModel, diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx index 66d2348be..b8cf9bec3 100644 --- a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx @@ -47,6 +47,7 @@ type ImageCanvasPublicationMaterialsDemoPanelViewProps = { ) => CSSProperties; onRequestUpload: (target: UploadTarget) => void; onRememberImageModel: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: (dialog: GenerateDialogState) => void; }; @@ -116,6 +117,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({ buildPortalMenuStyle, onRequestUpload, onRememberImageModel, + hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasPublicationMaterialsDemoPanelViewProps) { const references = dialog.publicationReferences ?? []; @@ -302,6 +304,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({ setGenerateDialog={setGenerateDialog} includeDimensions={false} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} lockedModel={IMAGE_MODEL_GPT_IMAGE_2} optionLabelPrefix="宣发素材" cost={calculateEditorImageModelPrice( diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx index 2b200d0c9..a09b8937a 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx @@ -13,7 +13,10 @@ import type { QuickEditPanelState, UploadTarget, } from './ImageCanvasEditorTypes'; -import { QUICK_EDIT_REFERENCE_LIMIT } from './ImageCanvasGenerationModel'; +import { + QUICK_EDIT_REFERENCE_LIMIT, + resolveExtraImageReferenceLimit, +} from './ImageCanvasGenerationModel'; export type ImageCanvasQuickEditPanelViewProps = { panel: QuickEditPanelState; @@ -31,6 +34,7 @@ export type ImageCanvasQuickEditPanelViewProps = { ) => CSSProperties; onRequestUpload?: (target: UploadTarget) => void; onRememberImageModel?: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: () => void; }; @@ -100,13 +104,20 @@ export function ImageCanvasQuickEditPanelView({ buildPortalMenuStyle = () => ({}), onRequestUpload, onRememberImageModel = () => {}, + hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasQuickEditPanelViewProps) { const isRedraw = panel.mode === 'redraw'; const isImageQuickEdit = !isRedraw && sourceLayer.mediaType !== 'video'; const referenceCount = panel.quickEditReferences?.length ?? 0; const canAddReferences = - !isRedraw && referenceCount < QUICK_EDIT_REFERENCE_LIMIT; + !isRedraw && + referenceCount < + resolveExtraImageReferenceLimit( + panel.model, + QUICK_EDIT_REFERENCE_LIMIT, + 1, + ); const quickEditDialog = createQuickEditDialog(panel); return ( @@ -141,6 +152,7 @@ export function ImageCanvasQuickEditPanelView({ } onToggleReferenceMenu={() => setIsReferenceMenuOpen?.((open) => !open)} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmit} dialogLabel={isRedraw ? '重绘图片' : '快速编辑图片'} includeDimensions={isRedraw || isImageQuickEdit} diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index ea1f2d18e..41303512b 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -58,6 +58,7 @@ type ImageCanvasSpecGenerationPanelViewProps = { onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void; onRequestUpload: (target: UploadTarget) => void; onRememberImageModel?: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: (dialog: GenerateDialogState) => void; }; @@ -79,6 +80,7 @@ export function ImageCanvasSpecGenerationPanelView({ onUpdateSpecFormValue, onRequestUpload, onRememberImageModel = () => {}, + hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasSpecGenerationPanelViewProps) { const isUiDesignDialog = dialog.mode === 'ui-design'; @@ -428,6 +430,7 @@ export function ImageCanvasSpecGenerationPanelView({ setGenerateDialog={setGenerateDialog ?? (() => undefined)} includeDimensions onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} lockedModel={IMAGE_MODEL_GPT_IMAGE_2} cost={calculateEditorUiDesignPrice( IMAGE_MODEL_GPT_IMAGE_2, @@ -473,7 +476,7 @@ export function ImageCanvasSpecGenerationPanelView({ size="xs" shape="pill" className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit" - disabled={isGenerating} + disabled={isGenerating || hasPendingImageReferenceUploads} aria-label="提交生成规范" > {isGenerating ? ( diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 035baf71d..eac4df1a2 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -76,6 +76,7 @@ export type ImageCanvasStageViewProps = { uiAssetExtractionSourceLayer: CanvasLayer | null; quickEditSelectionState: UiAssetExtractionState | null; quickEditSelectionSourceLayer: CanvasLayer | null; + hasPendingImageReferenceUploads?: boolean; generationComposerStyle: CSSProperties | null; selectedToolbarStyle: CSSProperties | null; splittingIconSpritesheetLayerIds?: ReadonlySet; @@ -236,6 +237,7 @@ export function ImageCanvasStageView({ uiAssetExtractionSourceLayer, quickEditSelectionState, quickEditSelectionSourceLayer, + hasPendingImageReferenceUploads = false, generationComposerStyle, selectedToolbarStyle, splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET, @@ -445,6 +447,7 @@ export function ImageCanvasStageView({ onRequestUpload={onRequestUiAssetExtractionReferenceUpload} onRemoveReference={onRemoveUiAssetExtractionReference} onSubmit={onSubmitUiAssetExtraction} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} /> { expect(onModelChange).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2); }); + it('disables extraction submission while reference uploads are pending', () => { + render( + , + ); + + expect( + (screen.getByRole('button', { name: '提取' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it('keeps the current UI extraction model and references when the target model is too small', () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + const onModelChange = vi.fn(); + + render( + ({ + id: `reference-${index + 1}`, + label: `参考图${index + 1}`, + src: `/reference-${index + 1}.png`, + })), + })} + onToolChange={vi.fn()} + onPointerStart={vi.fn()} + onPointerMove={vi.fn()} + onPointerEnd={vi.fn()} + onModelChange={onModelChange} + onSubmit={vi.fn()} + />, + ); + + fireEvent.click( + screen.getByRole('button', { name: '提取素材模型 nanobanana2' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '提取素材模型选项' })).getByRole( + 'button', + { name: 'gpt-image-2' }, + ), + ); + + expect(alertMock).toHaveBeenCalledWith( + '当前已有 5 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换', + ); + expect(onModelChange).not.toHaveBeenCalled(); + expect(screen.getByRole('menu', { name: '提取素材模型选项' })).toBeTruthy(); + }); + it('crops mark previews from the same source coordinates as the red selection', () => { render( void; onRemoveReference?: (referenceId: string) => void; onSubmit?: () => void; + hasPendingImageReferenceUploads?: boolean; }; const TOOL_OPTIONS: Array<{ @@ -275,6 +278,7 @@ export function ImageCanvasUiAssetExtractionOverlayView({ onRequestUpload, onRemoveReference, onSubmit, + hasPendingImageReferenceUploads = false, }: ImageCanvasUiAssetExtractionOverlayViewProps) { const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); if (!sourceLayer || !state) { @@ -282,7 +286,10 @@ export function ImageCanvasUiAssetExtractionOverlayView({ } const isQuickEdit = variant === 'quick-edit'; const marks = [...state.marks, ...(state.draftMark ? [state.draftMark] : [])]; - const canSubmit = state.marks.length > 0 && state.status !== 'extracting'; + const canSubmit = + state.marks.length > 0 && + state.status !== 'extracting' && + !hasPendingImageReferenceUploads; const extractionPlan = resolveUiAssetExtractionGenerationPlan( state.marks.length, ); @@ -487,6 +494,18 @@ export function ImageCanvasUiAssetExtractionOverlayView({ className="image-canvas-editor__option-popover-choice image-canvas-editor__option-popover-choice--model" aria-pressed={selected} onClick={() => { + const referenceLimit = + resolveExtraImageReferenceLimit( + option.value, + UI_EXTRA_REFERENCE_LIMIT, + 1, + ); + if (state.references.length > referenceLimit) { + window.alert( + `当前已有 ${state.references.length} 张参考图,${option.label} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`, + ); + return; + } onModelChange(option.value); setIsModelMenuOpen(false); }} diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index fed0c5e1d..c4d1a2f62 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -10,7 +10,12 @@ import type { GenerateDialogState, QuickEditPanelState, } from './ImageCanvasEditorTypes'; -import { appendLimitedQuickEditReferences } from './ImageCanvasGenerationModel'; +import { + appendLimitedImageReferences, + QUICK_EDIT_REFERENCE_LIMIT, + resolveDialogExtraImageReferenceLimit, + resolveExtraImageReferenceLimit, +} from './ImageCanvasGenerationModel'; type CanvasSize = { width: number; height: number }; type CanvasPoint = { x: number; y: number }; @@ -173,6 +178,7 @@ export function applyGenerationReferenceUpload({ ? { ...setFailedGenerationIdle(dialog), characterSpecReference: firstReference, + characterReferences: dialog.characterReferences, } : dialog; } @@ -181,6 +187,11 @@ export function applyGenerationReferenceUpload({ ? { ...setFailedGenerationIdle(dialog), iconSpecReference: firstReference, + generationReferences: appendLimitedImageReferences( + [], + dialog.generationReferences ?? [], + resolveDialogExtraImageReferenceLimit(dialog), + ), } : dialog; } @@ -189,6 +200,7 @@ export function applyGenerationReferenceUpload({ ? { ...setFailedGenerationIdle(dialog), uiDesignSpecReference: firstReference, + generationReferences: dialog.generationReferences, } : dialog; } @@ -196,10 +208,11 @@ export function applyGenerationReferenceUpload({ return dialog?.mode === 'publication' ? { ...setFailedGenerationIdle(dialog), - publicationReferences: [ - ...(dialog.publicationReferences ?? []), - ...references, - ], + publicationReferences: appendLimitedImageReferences( + dialog.publicationReferences, + references, + resolveDialogExtraImageReferenceLimit(dialog), + ), composerOpen: true, } : dialog; @@ -212,10 +225,18 @@ export function applyGenerationReferenceUpload({ dialog?.mode === 'ui-design' ? { ...setFailedGenerationIdle(dialog), - generationReferences: [ - ...(dialog.generationReferences ?? []), - ...references, - ], + generationReferences: + dialog.mode === 'video' + ? appendLimitedVideoReferences( + dialog.generationReferences ?? [], + references, + 'image', + ) + : appendLimitedImageReferences( + dialog.generationReferences, + references, + resolveDialogExtraImageReferenceLimit(dialog), + ), } : dialog; } @@ -245,10 +266,11 @@ export function applyGenerationReferenceUpload({ return dialog?.mode === 'character' ? { ...setFailedGenerationIdle(dialog), - characterReferences: [ - ...(dialog.characterReferences ?? []), - ...references, - ], + characterReferences: appendLimitedImageReferences( + dialog.characterReferences, + references, + resolveDialogExtraImageReferenceLimit(dialog), + ), } : dialog; } @@ -267,9 +289,14 @@ export function applyQuickEditReferenceUpload({ ...panel, status: panel.status === 'failed' ? 'idle' : panel.status, errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage, - quickEditReferences: appendLimitedQuickEditReferences( + quickEditReferences: appendLimitedImageReferences( panel.quickEditReferences, references, + resolveExtraImageReferenceLimit( + panel.model, + QUICK_EDIT_REFERENCE_LIMIT, + 1, + ), ), }; } diff --git a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx index eeb2d117f..b18f151d9 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx +++ b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx @@ -72,6 +72,78 @@ function durablePerfectPixelDialog( } describe('useCanvasGenerationDialogs', () => { + it('keeps the active dialog context stable while reference uploads are pending', () => { + let locked = false; + const onContextMutationRejected = vi.fn(); + const { result } = renderHook(() => + useCanvasGenerationDialogs({ + isContextMutationLocked: () => locked, + onContextMutationRejected, + }), + ); + + act(() => { + result.current.openCanvasGenerationDialog( + createDialog('generate', 'first'), + ); + }); + const activeId = result.current.activeCanvasGenerationDialog?.id; + expect(activeId).toBeTruthy(); + + locked = true; + act(() => { + result.current.openCanvasGenerationDialog( + createDialog('character', 'second'), + ); + result.current.setGenerateDialog(null); + }); + + expect(result.current.activeCanvasGenerationDialog).toEqual( + expect.objectContaining({ id: activeId, prompt: 'first' }), + ); + expect(result.current.inactiveGenerateDialogs).toEqual([]); + expect(onContextMutationRejected).toHaveBeenCalledTimes(2); + + act(() => { + result.current.setGenerateDialog((currentDialog) => + currentDialog ? { ...currentDialog, prompt: 'updated' } : currentDialog, + ); + }); + expect(result.current.activeCanvasGenerationDialog?.prompt).toBe('updated'); + }); + + it('rejects switching between same-mode non-canvas dialogs while uploads are pending', () => { + let locked = false; + const onContextMutationRejected = vi.fn(); + const { result } = renderHook(() => + useCanvasGenerationDialogs({ + isContextMutationLocked: () => locked, + onContextMutationRejected, + }), + ); + + act(() => { + result.current.setGenerateDialog({ + mode: 'edit', + sourceLayerId: 'layer-a', + prompt: '', + status: 'idle', + }); + }); + locked = true; + act(() => { + result.current.setGenerateDialog({ + mode: 'edit', + sourceLayerId: 'layer-b', + prompt: '', + status: 'idle', + }); + }); + + expect(result.current.generateDialog?.sourceLayerId).toBe('layer-a'); + expect(onContextMutationRejected).toHaveBeenCalledTimes(1); + }); + it('archives, activates, updates, and removes canvas generation dialogs', () => { const onActivate = vi.fn(); const { result } = renderHook(() => diff --git a/src/components/image-editor/useCanvasGenerationDialogs.ts b/src/components/image-editor/useCanvasGenerationDialogs.ts index bbcdd69cb..485c39f28 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.ts +++ b/src/components/image-editor/useCanvasGenerationDialogs.ts @@ -75,10 +75,23 @@ function withGenerationTimestamps( }; } +function generationDialogContextId(dialog: GenerateDialogState | null) { + if (!dialog) { + return null; + } + return isCanvasGenerationDialog(dialog) + ? dialog.id + : `${dialog.mode}:${dialog.sourceLayerId ?? 'draft'}`; +} + export function useCanvasGenerationDialogs({ onActivate, + isContextMutationLocked, + onContextMutationRejected, }: { onActivate?: () => void; + isContextMutationLocked?: () => boolean; + onContextMutationRejected?: () => void; } = {}) { const generationDialogCounterRef = useRef(0); const generateDialogRef = useRef(null); @@ -96,21 +109,37 @@ export function useCanvasGenerationDialogs({ ? generateDialog : null; + const rejectContextMutation = useCallback(() => { + if (!isContextMutationLocked?.()) { + return false; + } + onContextMutationRejected?.(); + return true; + }, [isContextMutationLocked, onContextMutationRejected]); + const setGenerateDialog = useCallback< Dispatch> - >((nextDialogOrUpdater) => { - const currentDialog = generateDialogRef.current; - const nextDialog = - typeof nextDialogOrUpdater === 'function' - ? nextDialogOrUpdater(currentDialog) - : nextDialogOrUpdater; - const nextDialogWithTimestamps = withGenerationTimestamps( - nextDialog, - currentDialog, - ); - generateDialogRef.current = nextDialogWithTimestamps; - setGenerateDialogState(nextDialogWithTimestamps); - }, []); + >( + (nextDialogOrUpdater) => { + const currentDialog = generateDialogRef.current; + const nextDialog = + typeof nextDialogOrUpdater === 'function' + ? nextDialogOrUpdater(currentDialog) + : nextDialogOrUpdater; + const nextDialogWithTimestamps = withGenerationTimestamps( + nextDialog, + currentDialog, + ); + const currentContextId = generationDialogContextId(currentDialog); + const nextContextId = generationDialogContextId(nextDialogWithTimestamps); + if (currentContextId !== nextContextId && rejectContextMutation()) { + return; + } + generateDialogRef.current = nextDialogWithTimestamps; + setGenerateDialogState(nextDialogWithTimestamps); + }, + [rejectContextMutation], + ); const canvasGenerationDialogs = useMemo( () => activeCanvasGenerationDialog @@ -137,6 +166,9 @@ export function useCanvasGenerationDialogs({ if (!isCanvasGenerationDialog(currentDialog)) { return; } + if (rejectContextMutation()) { + return; + } const nextInactiveDialogs = inactiveGenerateDialogsRef.current.some( (dialog) => dialog.id === currentDialog.id, ) @@ -150,11 +182,14 @@ export function useCanvasGenerationDialogs({ ]; inactiveGenerateDialogsRef.current = nextInactiveDialogs; setInactiveGenerateDialogs(nextInactiveDialogs); - }, []); + }, [rejectContextMutation]); const openCanvasGenerationDialog = useCallback( (dialog: CanvasGenerationDialogDraft) => { const currentDialog = generateDialogRef.current; + if (rejectContextMutation()) { + return isCanvasGenerationDialog(currentDialog) ? currentDialog.id : ''; + } if (isCanvasGenerationDialog(currentDialog)) { inactiveGenerateDialogsRef.current = inactiveGenerateDialogsRef.current.some( @@ -193,12 +228,21 @@ export function useCanvasGenerationDialogs({ archiveActiveCanvasGenerationDialog, createGenerationDialogId, getCanvasGenerationDialogsSnapshot, + rejectContextMutation, ], ); const updateCanvasGenerationDialogById = useCallback( (dialogId: string, updater: CanvasGenerationDialogUpdater) => { const currentDialogRef = generateDialogRef.current; + if ( + isCanvasGenerationDialog(currentDialogRef) && + currentDialogRef.id === dialogId && + updater(currentDialogRef) === null && + rejectContextMutation() + ) { + return; + } if ( isCanvasGenerationDialog(currentDialogRef) && currentDialogRef.id === dialogId @@ -235,7 +279,7 @@ export function useCanvasGenerationDialogs({ }), ); }, - [], + [rejectContextMutation], ); // 中文注释:低层删除不再对未收口的完美像素 operation 抗命。删除占位不撤销任何在途请求 @@ -260,6 +304,13 @@ export function useCanvasGenerationDialogs({ const activateCanvasGenerationDialog = useCallback( (targetDialog: CanvasGenerationDialogState) => { const currentDialog = generateDialogRef.current; + if ( + (!isCanvasGenerationDialog(currentDialog) || + currentDialog.id !== targetDialog.id) && + rejectContextMutation() + ) { + return; + } const nextInactiveDialogs = inactiveGenerateDialogsRef.current.filter( (dialog) => dialog.id !== targetDialog.id, ); @@ -282,7 +333,7 @@ export function useCanvasGenerationDialogs({ setGenerateDialogState(nextActiveDialog); onActivate?.(); }, - [onActivate], + [onActivate, rejectContextMutation], ); const restoreCanvasGenerationDialogs = useCallback( @@ -328,6 +379,13 @@ export function useCanvasGenerationDialogs({ dialog.sourceLayerId !== targetLayerId && dialog.generatedLayerId !== targetLayerId; const currentDialog = generateDialogRef.current; + if ( + isCanvasGenerationDialog(currentDialog) && + !keepDialog(currentDialog) && + rejectContextMutation() + ) { + return; + } const nextActiveDialog = isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog) ? null @@ -339,7 +397,7 @@ export function useCanvasGenerationDialogs({ setGenerateDialogState(nextActiveDialog); setInactiveGenerateDialogs(nextInactiveDialogs); }, - [], + [rejectContextMutation], ); const getGeneratingDialogPlaceholder = useCallback( diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx b/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx index b1d3465cf..3c036c048 100644 --- a/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx @@ -76,7 +76,10 @@ function AssetCanvasBridgeHarness({ selectSingleLayer = vi.fn(), }: { asset?: EditorAsset; - resolveCanvasPoint?: (clientX: number, clientY: number) => { + resolveCanvasPoint?: ( + clientX: number, + clientY: number, + ) => { x: number; y: number; } | null; @@ -97,7 +100,9 @@ function AssetCanvasBridgeHarness({ const suppressAssetClickRef = useRef(false); const layerCounterRef = useRef(0); const [activeUploadFolderId, setActiveUploadFolderId] = useState('project'); - const [hoveredLayerId, setHoveredLayerId] = useState('hovered'); + const [hoveredLayerId, setHoveredLayerId] = useState( + 'hovered', + ); const [assetPointerDrag, setAssetPointerDrag] = useState(assetPointerDragRef.current); const [uploadDropTarget, setUploadDropTarget] = useState< @@ -132,10 +137,7 @@ function AssetCanvasBridgeHarness({ return (
- {activeUploadFolderId} @@ -158,17 +160,21 @@ function AssetCleanupHarness({ }), ], onDeleteLayerSideEffects = vi.fn(), + canDeleteLayers, discardHistoryEntriesContainingLayer = vi.fn(), }: { deletedAssets?: EditorAsset[]; initialLayers?: CanvasLayer[]; onDeleteLayerSideEffects?: (layerId: string) => void; + canDeleteLayers?: (targetLayerIds: string[]) => boolean; discardHistoryEntriesContainingLayer?: ( matchesLayer: (layer: CanvasLayer) => boolean, ) => void; }) { const [layers, setLayers] = useState(initialLayers); - const [selectedLayerId, setSelectedLayerId] = useState('linked'); + const [selectedLayerId, setSelectedLayerId] = useState( + 'linked', + ); const [selectedLayerIds, setSelectedLayerIds] = useState(['linked', 'kept']); const cleanup = useImageCanvasAssetLayerCleanup({ layers, @@ -176,6 +182,7 @@ function AssetCleanupHarness({ setSelectedLayerId, setSelectedLayerIds, onDeleteLayerSideEffects, + canDeleteLayers, discardHistoryEntriesContainingLayer, }); @@ -184,7 +191,9 @@ function AssetCleanupHarness({ - {layers.map((layer) => layer.id).join(',')} + + {layers.map((layer) => layer.id).join(',')} + {selectedLayerId ?? '-'} {selectedLayerIds.join(',')}
@@ -292,6 +301,32 @@ describe('useImageCanvasAssetCanvasBridge', () => { ).toBe(false); }); + it('keeps linked layers and history when asset cleanup is blocked', () => { + const onDeleteLayerSideEffects = vi.fn(); + const discardHistoryEntriesContainingLayer = vi.fn(); + const canDeleteLayers = vi.fn().mockReturnValue(false); + render( + , + ); + + act(() => { + screen.getByRole('button', { name: '清理素材' }).click(); + }); + + expect(canDeleteLayers).toHaveBeenCalledWith(['linked']); + expect(screen.getByTestId('layers').textContent).toBe('linked,kept'); + expect(screen.getByTestId('selected').textContent).toBe('linked'); + expect(onDeleteLayerSideEffects).not.toHaveBeenCalled(); + expect(discardHistoryEntriesContainingLayer).not.toHaveBeenCalled(); + }); + it('invalidates matching history even when no linked layer is currently mounted', () => { const discardHistoryEntriesContainingLayer = vi.fn(); render( diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts index 345a2dde9..4b361aa12 100644 --- a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts @@ -36,6 +36,7 @@ type UseImageCanvasAssetLayerCleanupOptions = { setSelectedLayerId: Dispatch>; setSelectedLayerIds: Dispatch>; onDeleteLayerSideEffects?: (layerId: string) => void; + canDeleteLayers?: (targetLayerIds: string[]) => boolean; discardHistoryEntriesContainingLayer?: ( matchesLayer: (layer: CanvasLayer) => boolean, ) => void; @@ -73,21 +74,28 @@ export function useImageCanvasAssetLayerCleanup({ setSelectedLayerId, setSelectedLayerIds, onDeleteLayerSideEffects, + canDeleteLayers, discardHistoryEntriesContainingLayer, }: UseImageCanvasAssetLayerCleanupOptions) { return useCallback( (deletedAssets: EditorAsset[]) => { if (!deletedAssets.length) { - return; + return true; } - discardHistoryEntriesContainingLayer?.((layer) => - deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), - ); const deletedLayerIds = layers .filter((layer) => deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), ) .map((layer) => layer.id); + if ( + deletedLayerIds.length > 0 && + canDeleteLayers?.(deletedLayerIds) === false + ) { + return false; + } + discardHistoryEntriesContainingLayer?.((layer) => + deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), + ); setLayers((currentLayers) => currentLayers.filter( (layer) => @@ -111,16 +119,18 @@ export function useImageCanvasAssetLayerCleanup({ } const currentLayer = layers.find((layer) => layer.id === currentId); return currentLayer && - deletedAssets.some((asset) => isLayerLinkedToAsset(currentLayer, asset)) + deletedAssets.some((asset) => + isLayerLinkedToAsset(currentLayer, asset), + ) ? null : currentId; }); - deletedLayerIds.forEach((layerId) => - onDeleteLayerSideEffects?.(layerId), - ); + deletedLayerIds.forEach((layerId) => onDeleteLayerSideEffects?.(layerId)); + return true; }, [ discardHistoryEntriesContainingLayer, + canDeleteLayers, layers, onDeleteLayerSideEffects, setLayers, diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx index b820c6efc..f71250921 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx @@ -36,7 +36,9 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }; }); -function createUploadedAsset(overrides: Partial = {}): EditorAsset { +function createUploadedAsset( + overrides: Partial = {}, +): EditorAsset { return { id: 'asset-a', label: '素材A', @@ -73,7 +75,7 @@ function AssetLibraryHarness({ }: { canAccessProtectedData?: boolean; openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void; - onDeleteAssets?: (assets: EditorAsset[]) => void; + onDeleteAssets?: (assets: EditorAsset[]) => boolean | void; }) { const assetListRef = useRef(null); const assetLibrary = useImageCanvasAssetLibrary({ @@ -292,9 +294,7 @@ describe('useImageCanvasAssetLibrary', () => { }), ); - render( - , - ); + render(); await waitFor(() => { expect(openEditorLoginModal).toHaveBeenCalledTimes(1); @@ -330,9 +330,7 @@ describe('useImageCanvasAssetLibrary', () => { }); act(() => screen.getByRole('button', { name: 'commit folder' }).click()); await waitFor(() => { - expect(screen.getByTestId('folders').textContent).toContain( - 'folder-', - ); + expect(screen.getByTestId('folders').textContent).toContain('folder-'); }); act(() => { resolveCreateFolder({ @@ -402,6 +400,20 @@ describe('useImageCanvasAssetLibrary', () => { expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a'); }); + it('keeps uploaded assets when canvas cleanup vetoes deletion', async () => { + const onDeleteAssets = vi.fn().mockReturnValue(false); + render(); + + await screen.findByText('素材A'); + act(() => screen.getByRole('button', { name: 'delete asset' }).click()); + + expect(screen.getByTestId('assets').textContent).toContain('asset-a:素材A'); + expect(onDeleteAssets).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'asset-a' }), + ]); + expect(deleteEditorAssetMock).not.toHaveBeenCalled(); + }); + it('selects and deletes selected uploaded assets', async () => { const onDeleteAssets = vi.fn(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts index f9a5cc191..56212a586 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.ts +++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts @@ -120,7 +120,7 @@ export function useImageCanvasAssetLibrary({ assetListRef: RefObject; canAccessProtectedData: boolean; openEditorLoginModal: (postLoginAction?: (() => void) | null) => void; - onDeleteAssets?: (assets: EditorAsset[]) => void; + onDeleteAssets?: (assets: EditorAsset[]) => boolean | void; }) { const [assetFolders, setAssetFolders] = useState(EDITOR_ASSET_FOLDERS); @@ -353,8 +353,10 @@ export function useImageCanvasAssetLibrary({ if (asset.sourceKind !== 'uploaded') { return; } + if (onDeleteAssets?.([asset]) === false) { + return; + } setAssets((currentAssets) => removeAssetById(currentAssets, asset.id)); - onDeleteAssets?.([asset]); setRenamingAsset((currentRename) => currentRename?.assetId === asset.id ? null : currentRename, ); @@ -506,10 +508,12 @@ export function useImageCanvasAssetLibrary({ const ids = [...selectedAssetIds]; const deletedAssets = removeSelectedAssets(assets, selectedAssetIds) .deletedAssets; + if (onDeleteAssets?.(deletedAssets) === false) { + return; + } setAssets( (currentAssets) => removeSelectedAssets(currentAssets, selectedAssetIds).assets, ); - onDeleteAssets?.(deletedAssets); setSelectedAssetIds(new Set()); ids.forEach((assetId) => { void deleteEditorAsset(assetId); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index eadf6cb82..a19a9aba3 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -67,6 +67,7 @@ type ImageCanvasGenerationSurfaceOptions = { iconSpecButtonRef: RefObject; generationReferenceButtonRef: RefObject; publicationReferenceButtonRef: RefObject; + hasPendingImageReferenceUploads?: boolean; generateDialog: GenerateDialogState | null; setGenerateDialog: Dispatch>; activeCanvasGenerationDialog: CanvasGenerationDialogState | null; @@ -166,6 +167,7 @@ export function useImageCanvasGenerationSurface({ iconSpecButtonRef, generationReferenceButtonRef, publicationReferenceButtonRef, + hasPendingImageReferenceUploads = false, generateDialog, setGenerateDialog, activeCanvasGenerationDialog, @@ -211,6 +213,7 @@ export function useImageCanvasGenerationSurface({ layerCounterRef, generateDialog, setGenerateDialog, + hasPendingImageReferenceUploads, openCanvasGenerationDialog, activateCanvasGenerationDialog, updateCanvasGenerationDialogById, @@ -427,6 +430,7 @@ export function useImageCanvasGenerationSurface({ generationWorkflow.isPickingUiDesignSpecFromCanvas } generateDialog={generateDialog} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} generationComposerStyle={generationComposerStyle} iconComposerStyle={iconComposerStyle} quickEditPanel={generationWorkflow.quickEditPanel} diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index e24b6ab90..7d489de68 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -338,6 +338,7 @@ function GenerationWorkflowHarness({ initialViewport = { x: 10, y: 20, scale: 2 }, projectId, currentUserId, + hasPendingImageReferenceUploads = false, applyProjectSnapshot, applyProjectSnapshotWithoutHistory, flushProjectPersistence, @@ -350,6 +351,7 @@ function GenerationWorkflowHarness({ initialViewport?: { x: number; y: number; scale: number }; projectId?: string; currentUserId?: string; + hasPendingImageReferenceUploads?: boolean; applyProjectSnapshot?: Parameters< typeof useImageCanvasGenerationWorkflow >[0]['applyProjectSnapshot']; @@ -407,6 +409,7 @@ function GenerationWorkflowHarness({ layerCounterRef, generateDialog: dialogs.generateDialog, setGenerateDialog: dialogs.setGenerateDialog, + hasPendingImageReferenceUploads, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, activateCanvasGenerationDialog: dialogs.activateCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, @@ -530,6 +533,9 @@ function GenerationWorkflowHarness({ {workflow.generationWarning ?? '-'} + + {workflow.isPickingGenerationReferenceFromCanvas ? 'picking' : 'idle'} + {workflow.quickEditPanel ? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}` @@ -976,6 +982,12 @@ function GenerationWorkflowHarness({ > 选择画布参考图 + + + +
- - {supervisorProgress?.taskProgress || status} - + {supervisorProgress?.taskProgress || status} {supervisorProgress?.currentWork || (projectReady ? '等待新的运行事件' : '请选择项目目录')}
+ {runtimeAppearsStalled && inactiveRuntimeMs !== null ? ( + + {`${formatGameChatDuration(inactiveRuntimeMs)}无新进度`} + + ) : null} {supervisorProgress?.activeAgents.length ? ( {`${supervisorProgress.activeAgents.length} 个专业 Agent 活跃`} ) : null} @@ -1187,7 +1354,15 @@ export function SupervisorChatOnlyView({ aria-live="polite" data-runtime-owned="true" > - {transientReply} + {transientReply} + {gameChatMode ? ( + + ) : null}

) : null} {running && !transientReply && !gameChatMode ? ( @@ -1464,7 +1639,9 @@ export function SupervisorChatOnlyView({ {runtimeEvents.length > 0 ? ( runtimeEvents.map((item) => (
-