diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 91e8f5e2d..64665dd61 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -107,8 +107,17 @@ const rustSharedContractSource = fs.readFileSync( ); const allowedUncalledTauriCommands = [ 'chat_with_game_creator_agent', + 'commit_local_project_asset', + 'create_local_project_asset_canvas_draft', + 'discard_local_project_asset_canvas_draft', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', + 'read_local_project_asset_canvas_draft', + 'read_local_project_asset_canvas_media', + 'recover_local_project_asset_canvas_transactions', + 'stage_local_project_asset_canvas_image', + 'store_local_project_asset_canvas_media', + 'update_local_project_asset_canvas_draft', ]; const sourceExtensions = new Set([ '.json', diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 4d425cdc5..ab0716f39 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1591,6 +1591,7 @@ dependencies = [ "base64 0.22.1", "chromiumoxide", "futures", + "getrandom 0.3.4", "http", "image", "jsonschema", @@ -2171,10 +2172,23 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "image-webp", "moxcms", "num-traits", "png 0.18.1", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 59f16c386..8188c853a 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -19,8 +19,9 @@ agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" } base64 = "0.22" chromiumoxide = "0.9.1" futures = "0.3" +getrandom = "0.3" http = "1" -image = { version = "0.25", default-features = false, features = ["png"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } jsonschema = { version = "0.49.3", default-features = false } oxc_allocator = "0.143.0" oxc_ast = "0.143.0" 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 bff274d4b..2601448d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -232,6 +232,94 @@ pub(crate) fn update_local_project_resource_canvas_layout( ) } +#[tauri::command] +pub(crate) fn create_local_project_asset_canvas_draft( + input: CreateAssetCanvasDraftInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + create_asset_canvas_draft_at(&root, &input) +} + +#[tauri::command] +pub(crate) fn read_local_project_asset_canvas_draft( + input: ReadAssetCanvasDraftInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + read_asset_canvas_draft_at(&root, &input) +} + +#[tauri::command] +pub(crate) fn update_local_project_asset_canvas_draft( + input: UpdateAssetCanvasDraftInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + update_asset_canvas_draft_at(&root, &input) +} + +#[tauri::command] +pub(crate) fn store_local_project_asset_canvas_media( + input: StoreAssetCanvasMediaInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + store_asset_canvas_media_at(&root, &input) +} + +#[tauri::command] +pub(crate) fn stage_local_project_asset_canvas_image( + input: StageAssetCanvasImageInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + stage_asset_canvas_image_at(&root, &input) +} + +#[tauri::command] +pub(crate) fn read_local_project_asset_canvas_media( + input: ReadAssetCanvasMediaInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + read_asset_canvas_media_at(&root, &input) +} + +#[tauri::command] +pub(crate) fn discard_local_project_asset_canvas_draft( + input: DiscardAssetCanvasDraftInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + discard_asset_canvas_draft_at(&root, &input) +} + +#[tauri::command] +pub(crate) fn commit_local_project_asset( + app: tauri::AppHandle, + input: CommitAssetCanvasInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + let execution = commit_asset_canvas_at(&root, &input)?; + if let Some(event) = execution.event.as_ref() { + publish_asset_canvas_event_after_commit_at(&root, event, |payload| { + app.emit("game-creator-local-asset-committed", payload) + .map_err(|error| error.to_string()) + }); + } + Ok(execution.result) +} + +#[tauri::command] +pub(crate) fn recover_local_project_asset_canvas_transactions( + app: tauri::AppHandle, + input: RecoverAssetCanvasTransactionsInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + let execution = recover_asset_canvas_transactions_at(&root, &input.expected_project_id)?; + for event in &execution.events { + publish_asset_canvas_event_after_commit_at(&root, event, |payload| { + app.emit("game-creator-local-asset-committed", payload) + .map_err(|error| error.to_string()) + }); + } + Ok(execution.result) +} + #[tauri::command] pub(crate) async fn control_agent_run( app: tauri::AppHandle, 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 9e62d76a0..90c183719 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2209,6 +2209,15 @@ fn main() { read_local_project_resource_canvas_layout, read_local_project_resource_graph, update_local_project_resource_canvas_layout, + create_local_project_asset_canvas_draft, + read_local_project_asset_canvas_draft, + update_local_project_asset_canvas_draft, + store_local_project_asset_canvas_media, + stage_local_project_asset_canvas_image, + read_local_project_asset_canvas_media, + discard_local_project_asset_canvas_draft, + recover_local_project_asset_canvas_transactions, + commit_local_project_asset, get_local_game_project_revision, get_local_game_manifest ]) 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 1099d78e8..3a21f48c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -4,6 +4,7 @@ use similar::TextDiff; use std::io::{Seek, SeekFrom}; mod agent_db; +mod asset_canvas; mod checkpoint; mod conversation; mod export; @@ -15,6 +16,7 @@ mod resource_layout; mod verification; pub(crate) use agent_db::*; +pub(crate) use asset_canvas::*; pub(crate) use checkpoint::*; pub(crate) use conversation::*; pub(crate) use export::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs new file mode 100644 index 000000000..e3cea5afe --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -0,0 +1,3055 @@ +use super::*; +use base64::Engine; +use image::GenericImageView; +use std::collections::{BTreeSet, HashSet}; +use std::fs::File; +use unicode_normalization::UnicodeNormalization; +use uuid::Uuid; + +const ASSET_CANVAS_SCHEMA_VERSION: &str = "game-creator-asset-canvas-draft.v1"; +const ASSET_CANVAS_COMMIT_SCHEMA_VERSION: &str = "game-creator-local-asset-commit.v1"; +const ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION: &str = "game-creator-local-asset-transaction.v1"; +const ASSET_CANVAS_EVENT_SCHEMA_VERSION: &str = "game-creator-local-asset-committed.v1"; +const ASSET_CANVAS_ROOT: &str = ".agent/workbench/asset-canvas"; +const ASSET_CANVAS_DRAFT_LOCK: &str = ".agent/workbench/asset-canvas/.drafts.lock"; +const ASSET_CANVAS_MAX_DRAFT_BYTES: usize = 2 * 1024 * 1024; +const ASSET_CANVAS_MAX_TRANSACTION_BYTES: usize = 1024 * 1024; +const ASSET_CANVAS_MAX_LEDGER_BYTES: usize = 16 * 1024 * 1024; +const ASSET_CANVAS_MAX_MEDIA_BYTES: usize = 64 * 1024 * 1024; +const ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES: u64 = 512 * 1024 * 1024; +const ASSET_CANVAS_MAX_LAYERS: usize = 4096; +const ASSET_CANVAS_MAX_GENERATIONS: usize = 64; +const ASSET_CANVAS_MAX_REFERENCES: usize = 128; +const ASSET_CANVAS_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const ASSET_CANVAS_MAX_DIMENSION: u32 = 16_384; +const ASSET_CANVAS_MAX_PIXELS: u64 = 268_435_456; +const ASSET_CANVAS_STAGING_TTL_MILLIS: u64 = 24 * 60 * 60 * 1000; +const ASSET_CANVAS_LOCK_WAIT_ATTEMPTS: usize = 100; +const ASSET_CANVAS_LOCK_WAIT_MILLIS: u64 = 10; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum AssetCanvasIntent { + Create, + Refine, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum AssetCanvasDraftStatus { + Editing, + Generating, + CommitPrepared, + Committed, + Cancelled, + ReconciliationRequired, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub(crate) enum AssetCanvasMediaRef { + ProjectAsset { + #[serde(rename = "assetId")] + asset_id: String, + }, + DraftMedia { + #[serde(rename = "mediaId")] + media_id: String, + #[serde(rename = "mediaType")] + media_type: String, + sha256: String, + #[serde(rename = "byteLength")] + byte_length: u64, + #[serde(rename = "pixelWidth")] + pixel_width: u32, + #[serde(rename = "pixelHeight")] + pixel_height: u32, + }, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AssetCanvasLayer { + pub(crate) layer_id: String, + pub(crate) resource_id: String, + pub(crate) title: String, + pub(crate) media_ref: AssetCanvasMediaRef, + pub(crate) x: f64, + pub(crate) y: f64, + pub(crate) width: f64, + pub(crate) height: f64, + pub(crate) original_width: f64, + pub(crate) original_height: f64, + pub(crate) z_index: u64, + pub(crate) group_id: Option, + pub(crate) hidden: bool, + pub(crate) locked: bool, + pub(crate) flip_x: bool, + pub(crate) flip_y: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AssetCanvasViewport { + pub(crate) x: f64, + pub(crate) y: f64, + pub(crate) scale: f64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AssetCanvasState { + pub(crate) viewport: AssetCanvasViewport, + pub(crate) background_color: String, + pub(crate) layers: Vec, + pub(crate) selected_layer_ids: Vec, + pub(crate) primary_selected_layer_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum AssetCanvasGenerationStatus { + Accepted, + Polling, + Completed, + Failed, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AssetCanvasGenerationRecord { + pub(crate) generation_id: String, + pub(crate) idempotency_key: String, + pub(crate) status: AssetCanvasGenerationStatus, + pub(crate) prompt: String, + pub(crate) operation_id: Option, + pub(crate) reference_resource_ids: Vec, + pub(crate) output_media_ids: Vec, + pub(crate) error_code: Option, + pub(crate) created_at: u64, + pub(crate) updated_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AssetCanvasPendingCommit { + pub(crate) commit_id: String, + pub(crate) idempotency_key: String, + pub(crate) request_fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AssetCanvasLastCommit { + pub(crate) commit_id: String, + pub(crate) idempotency_key: String, + pub(crate) asset_id: String, + pub(crate) event_id: String, + pub(crate) committed_project_revision: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AssetCanvasDraft { + pub(crate) schema_version: String, + pub(crate) draft_id: String, + pub(crate) project_id: String, + pub(crate) intent: AssetCanvasIntent, + pub(crate) source_asset_id: Option, + pub(crate) source_resource_id: Option, + pub(crate) revision: u64, + pub(crate) status: AssetCanvasDraftStatus, + pub(crate) canvas: AssetCanvasState, + pub(crate) generations: Vec, + pub(crate) pending_commit: Option, + pub(crate) last_commit: Option, + pub(crate) created_at: u64, + pub(crate) updated_at: u64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct CreateAssetCanvasDraftInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) draft_id: String, + pub(crate) intent: AssetCanvasIntent, + pub(crate) source_asset_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum CreateAssetCanvasDraftStatus { + Created, + Existing, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CreateAssetCanvasDraftResult { + pub(crate) status: CreateAssetCanvasDraftStatus, + pub(crate) draft: AssetCanvasDraft, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ReadAssetCanvasDraftInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) draft_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ReadAssetCanvasDraftStatus { + Found, + NotFound, + ProjectIdentityConflict, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReadAssetCanvasDraftResult { + pub(crate) status: ReadAssetCanvasDraftStatus, + pub(crate) draft: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct UpdateAssetCanvasDraftInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) draft_id: String, + pub(crate) expected_draft_revision: u64, + pub(crate) status: AssetCanvasDraftStatus, + pub(crate) canvas: AssetCanvasState, + pub(crate) generations: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum UpdateAssetCanvasDraftStatus { + Updated, + Conflict, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateAssetCanvasDraftResult { + pub(crate) status: UpdateAssetCanvasDraftStatus, + pub(crate) draft: AssetCanvasDraft, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct StoreAssetCanvasMediaInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) draft_id: String, + pub(crate) expected_draft_revision: u64, + pub(crate) media_type: String, + pub(crate) bytes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StoreAssetCanvasMediaResult { + pub(crate) status: String, + pub(crate) draft_id: String, + pub(crate) draft_revision: u64, + pub(crate) media_ref: Option, + pub(crate) draft: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct StageAssetCanvasImageInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) draft_id: String, + pub(crate) expected_draft_revision: u64, + pub(crate) media_type: String, + pub(crate) bytes: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AssetCanvasStagedImage { + schema_version: String, + project_id: String, + draft_id: String, + draft_revision: u64, + staged_image_token: String, + media_type: String, + sha256: String, + byte_length: u64, + pixel_width: u32, + pixel_height: u32, + expires_at: u64, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StageAssetCanvasImageResult { + pub(crate) status: String, + pub(crate) staged_image_token: Option, + pub(crate) draft_id: String, + pub(crate) draft_revision: u64, + pub(crate) media_type: Option, + pub(crate) sha256: Option, + pub(crate) byte_length: Option, + pub(crate) pixel_width: Option, + pub(crate) pixel_height: Option, + pub(crate) expires_at: Option, + pub(crate) draft: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ReadAssetCanvasMediaInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) draft_id: String, + pub(crate) media_ref: AssetCanvasMediaRef, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReadAssetCanvasMediaResult { + pub(crate) media_type: String, + pub(crate) bytes: Vec, +} + +#[derive(Debug)] +struct AssetCanvasDraftLock { + _file: File, +} + +fn asset_canvas_now() -> u64 { + unix_millis().min(u128::from(ASSET_CANVAS_MAX_SAFE_INTEGER)) as u64 +} + +fn asset_canvas_sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn new_asset_canvas_token() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes).map_err(|_| "生成素材画布安全随机身份失败".to_string())?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) +} + +fn validate_uuid_v4(value: &str, label: &str) -> Result<(), String> { + let value = value.trim(); + let parsed = Uuid::parse_str(value).map_err(|_| format!("{label} 必须是小写 UUID v4"))?; + if parsed.get_version_num() != 4 || parsed.hyphenated().to_string() != value { + return Err(format!("{label} 必须是小写 UUID v4")); + } + Ok(()) +} + +fn validate_safe_revision(value: u64, label: &str) -> Result<(), String> { + if value > ASSET_CANVAS_MAX_SAFE_INTEGER { + return Err(format!("{label} 超过 JavaScript 安全整数上限")); + } + Ok(()) +} + +fn asset_canvas_draft_relative_path(draft_id: &str) -> String { + format!("{ASSET_CANVAS_ROOT}/drafts/{draft_id}.json") +} + +fn asset_canvas_recovery_relative_path(draft_id: &str) -> String { + format!("{ASSET_CANVAS_ROOT}/drafts/.recovery/{draft_id}.json") +} + +fn asset_canvas_recovery_sha_relative_path(draft_id: &str) -> String { + format!("{ASSET_CANVAS_ROOT}/drafts/.recovery/{draft_id}.sha256") +} + +fn current_asset_canvas_manifest(root: &Path) -> Result { + validate_project_root(root)?; + let path = resolve_local_project_path(root, ".agent/manifest.json")?; + let manifest = read_manifest(&path)?; + if manifest.project_id.trim().is_empty() { + return Err("项目 manifest 缺少 projectId".to_string()); + } + Ok(manifest) +} + +fn validate_asset_canvas_project_identity( + root: &Path, + expected_project_id: &str, +) -> Result { + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("expectedProjectId 不能为空".to_string()); + } + let manifest = current_asset_canvas_manifest(root)?; + if manifest.project_id != expected_project_id { + return Err("expectedProjectId 与当前项目不匹配".to_string()); + } + Ok(manifest) +} + +fn validate_plain_component(value: &str, label: &str, max_chars: usize) -> Result<(), String> { + let value = value.trim(); + if value.is_empty() + || value.chars().count() > max_chars + || value.chars().any(char::is_control) + || value.contains('/') + || value.contains('\\') + || matches!(value, "." | "..") + { + return Err(format!("{label} 无效")); + } + Ok(()) +} + +fn media_extension(media_type: &str) -> Result<&'static str, String> { + match media_type.trim().to_ascii_lowercase().as_str() { + "image/png" => Ok("png"), + "image/jpeg" => Ok("jpg"), + "image/webp" => Ok("webp"), + _ => Err("只支持 PNG、JPEG 和 WebP 图片".to_string()), + } +} + +fn validate_image_bytes(media_type: &str, bytes: &[u8]) -> Result<(String, u32, u32), String> { + if bytes.is_empty() || bytes.len() > ASSET_CANVAS_MAX_MEDIA_BYTES { + return Err(format!( + "图片大小必须在 1..={ASSET_CANVAS_MAX_MEDIA_BYTES} 字节" + )); + } + let normalized = match media_extension(media_type)? { + "png" => "image/png", + "jpg" => "image/jpeg", + "webp" => "image/webp", + _ => unreachable!(), + }; + let signature_matches = match normalized { + "image/png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), + "image/jpeg" => bytes.starts_with(&[0xff, 0xd8, 0xff]), + "image/webp" => bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP", + _ => false, + }; + if !signature_matches { + return Err("图片文件签名与媒体类型不匹配".to_string()); + } + let decoded = image::load_from_memory(bytes).map_err(|_| "图片无法完整解码".to_string())?; + let (width, height) = decoded.dimensions(); + if width == 0 + || height == 0 + || width > ASSET_CANVAS_MAX_DIMENSION + || height > ASSET_CANVAS_MAX_DIMENSION + || u64::from(width) * u64::from(height) > ASSET_CANVAS_MAX_PIXELS + { + return Err("图片尺寸超过素材画布上限".to_string()); + } + Ok((normalized.to_string(), width, height)) +} + +fn validate_asset_canvas_state(canvas: &AssetCanvasState) -> Result<(), String> { + if canvas.layers.len() > ASSET_CANVAS_MAX_LAYERS { + return Err(format!("素材画布最多支持 {ASSET_CANVAS_MAX_LAYERS} 个图层")); + } + if !canvas.viewport.x.is_finite() + || !canvas.viewport.y.is_finite() + || !canvas.viewport.scale.is_finite() + || !(0.025..=3.2).contains(&canvas.viewport.scale) + { + return Err("素材画布 viewport 无效".to_string()); + } + let mut layer_ids = HashSet::with_capacity(canvas.layers.len()); + let mut resource_ids = HashSet::with_capacity(canvas.layers.len()); + let mut z_indexes = HashSet::with_capacity(canvas.layers.len()); + for layer in &canvas.layers { + validate_plain_component(&layer.layer_id, "layerId", 512)?; + validate_plain_component(&layer.resource_id, "resourceId", 512)?; + if !layer_ids.insert(layer.layer_id.as_str()) + || !resource_ids.insert(layer.resource_id.as_str()) + || !z_indexes.insert(layer.z_index) + { + return Err("素材画布图层身份或层序重复".to_string()); + } + validate_safe_revision(layer.z_index, "zIndex")?; + if layer.title.chars().count() > 200 + || layer.title.chars().any(char::is_control) + || !layer.x.is_finite() + || !layer.y.is_finite() + || !layer.width.is_finite() + || !layer.height.is_finite() + || !layer.original_width.is_finite() + || !layer.original_height.is_finite() + || layer.width <= 0.0 + || layer.height <= 0.0 + || layer.original_width <= 0.0 + || layer.original_height <= 0.0 + { + return Err("素材画布图层字段无效".to_string()); + } + } + let selected = canvas + .selected_layer_ids + .iter() + .map(String::as_str) + .collect::>(); + if selected.len() != canvas.selected_layer_ids.len() + || selected.iter().any(|id| { + canvas + .layers + .iter() + .find(|layer| layer.layer_id == **id) + .is_none_or(|layer| layer.hidden) + }) + || canvas + .primary_selected_layer_id + .as_deref() + .is_some_and(|id| !selected.contains(id)) + { + return Err("素材画布选择引用无效".to_string()); + } + Ok(()) +} + +fn validate_generation_records(records: &[AssetCanvasGenerationRecord]) -> Result<(), String> { + if records.len() > ASSET_CANVAS_MAX_GENERATIONS { + return Err(format!( + "素材画布最多保存 {ASSET_CANVAS_MAX_GENERATIONS} 条生成记录" + )); + } + let mut ids = HashSet::with_capacity(records.len()); + for record in records { + validate_uuid_v4(&record.generation_id, "generationId")?; + validate_uuid_v4(&record.idempotency_key, "generation idempotencyKey")?; + if !ids.insert(record.generation_id.as_str()) + || record.prompt.chars().count() > 32_000 + || record.reference_resource_ids.len() > ASSET_CANVAS_MAX_REFERENCES + { + return Err("素材画布生成记录无效".to_string()); + } + } + Ok(()) +} + +fn validate_asset_canvas_draft( + draft: &AssetCanvasDraft, + expected_project_id: &str, + expected_draft_id: &str, +) -> Result<(), String> { + if draft.schema_version != ASSET_CANVAS_SCHEMA_VERSION + || draft.project_id != expected_project_id + || draft.draft_id != expected_draft_id + { + return Err("素材画布草稿 schema 或身份不匹配".to_string()); + } + validate_uuid_v4(&draft.draft_id, "draftId")?; + validate_safe_revision(draft.revision, "草稿 revision")?; + match draft.intent { + AssetCanvasIntent::Create + if draft.source_asset_id.is_some() || draft.source_resource_id.is_some() => + { + return Err("create 草稿不能包含源资源".to_string()); + } + AssetCanvasIntent::Refine + if draft.source_asset_id.is_none() || draft.source_resource_id.is_none() => + { + return Err("refine 草稿缺少源资源".to_string()); + } + _ => {} + } + validate_asset_canvas_state(&draft.canvas)?; + validate_generation_records(&draft.generations) +} + +fn read_asset_canvas_bytes( + root: &Path, + relative_path: &str, + label: &str, + max_bytes: usize, +) -> Result>, String> { + let path = resolve_local_project_path(root, relative_path)?; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(format!("{label} 必须是普通文件")); + } + Ok(metadata) if metadata.len() > max_bytes as u64 => { + return Err(format!("{label} 超过 {max_bytes} 字节上限")); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "读取 {label} 元数据失败:{}: {error}", + path.display() + )) + } + } + let (file, metadata) = open_project_snapshot_regular_file(&path, label)?; + if metadata.len() > max_bytes as u64 { + return Err(format!("{label} 超过 {max_bytes} 字节上限")); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take((max_bytes + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取 {label} 失败:{}: {error}", path.display()))?; + if bytes.len() > max_bytes { + return Err(format!("{label} 超过 {max_bytes} 字节上限")); + } + Ok(Some(bytes)) +} + +fn read_asset_canvas_draft_candidate( + root: &Path, + relative_path: &str, + label: &str, + project_id: &str, + draft_id: &str, +) -> Result)>, String> { + let Some(bytes) = + read_asset_canvas_bytes(root, relative_path, label, ASSET_CANVAS_MAX_DRAFT_BYTES)? + else { + return Ok(None); + }; + let draft = serde_json::from_slice::(&bytes) + .map_err(|error| format!("解析 {label} 失败:{error}"))?; + validate_asset_canvas_draft(&draft, project_id, draft_id)?; + let manifest = current_asset_canvas_manifest(root)?; + if manifest.project_id != project_id { + return Err("素材画布草稿项目身份在读取期间发生变化".to_string()); + } + validate_asset_canvas_draft_media(root, &manifest, &draft)?; + Ok(Some((draft, bytes))) +} + +fn validate_asset_canvas_draft_media( + root: &Path, + manifest: &GameCreationAppManifest, + draft: &AssetCanvasDraft, +) -> Result<(), String> { + for layer in &draft.canvas.layers { + match &layer.media_ref { + AssetCanvasMediaRef::ProjectAsset { asset_id } => { + project_asset_image(root, manifest, asset_id)?; + } + media_ref @ AssetCanvasMediaRef::DraftMedia { + byte_length, + pixel_width, + pixel_height, + .. + } => { + let (relative, media_type, expected_sha256) = + draft_media_relative_path(&draft.draft_id, media_ref)?; + let path = resolve_local_project_path(root, &relative)?; + let (bytes, width, height) = + open_and_validate_image_file(&path, &media_type, expected_sha256.as_deref())?; + if bytes.len() as u64 != *byte_length + || width != *pixel_width + || height != *pixel_height + { + return Err("素材画布草稿媒体元数据不匹配".to_string()); + } + } + } + } + Ok(()) +} + +fn write_asset_canvas_raw_sidecar( + root: &Path, + relative_path: &str, + label: &str, + bytes: &[u8], + max_bytes: usize, +) -> Result<(), String> { + if bytes.len() > max_bytes { + return Err(format!("{label} 超过 {max_bytes} 字节上限")); + } + let mut path = resolve_local_project_path(root, relative_path)?; + let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; + fs::create_dir_all(parent).map_err(|error| format!("创建 {label} 目录失败:{error}"))?; + path = resolve_local_project_path(root, relative_path)?; + if fs::symlink_metadata(&path).is_ok() { + open_project_snapshot_regular_file(&path, label)?; + } + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("sidecar"); + let temp_path = path.with_file_name(format!( + ".{file_name}.tmp.{}.{}", + std::process::id(), + asset_canvas_now() + )); + let backup_path = path.with_file_name(format!(".{file_name}.previous")); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + } + let mut file = options + .open(&temp_path) + .map_err(|error| format!("创建 {label} 临时文件失败:{error}"))?; + file.write_all(bytes) + .and_then(|_| file.sync_data()) + .map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!("写入 {label} 临时文件失败:{error}") + })?; + drop(file); + match fs::rename(&temp_path, &path) { + Ok(()) => {} + Err(first_error) => { + if let Ok(metadata) = fs::symlink_metadata(&backup_path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + let _ = fs::remove_file(&temp_path); + return Err(format!("{label} 恢复副本必须是普通文件")); + } + fs::remove_file(&backup_path) + .map_err(|error| format!("清理 {label} 恢复副本失败:{error}"))?; + } + fs::rename(&path, &backup_path) + .map_err(|error| format!("准备替换 {label} 失败:{first_error};{error}"))?; + if let Err(error) = fs::rename(&temp_path, &path) { + let _ = fs::rename(&backup_path, &path); + let _ = fs::remove_file(&temp_path); + return Err(format!("替换 {label} 失败:{error}")); + } + fs::remove_file(&backup_path) + .map_err(|error| format!("清理 {label} 恢复副本失败:{error}"))?; + } + } + #[cfg(unix)] + File::open(path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("同步 {label} 目录失败:{error}"))?; + Ok(()) +} + +fn read_asset_canvas_draft_locked( + root: &Path, + project_id: &str, + draft_id: &str, +) -> Result, String> { + let relative = asset_canvas_draft_relative_path(draft_id); + match read_asset_canvas_draft_candidate(root, &relative, "素材画布草稿", project_id, draft_id) + { + Ok(Some((draft, _))) => return Ok(Some(draft)), + Ok(None) => { + if read_asset_canvas_bytes( + root, + &asset_canvas_recovery_relative_path(draft_id), + "素材画布草稿恢复副本", + ASSET_CANVAS_MAX_DRAFT_BYTES, + )? + .is_none() + { + return Ok(None); + } + } + Err(_) => {} + } + let recovery_relative = asset_canvas_recovery_relative_path(draft_id); + let recovery_sha_relative = asset_canvas_recovery_sha_relative_path(draft_id); + let Some((recovered, recovery_bytes)) = read_asset_canvas_draft_candidate( + root, + &recovery_relative, + "素材画布草稿恢复副本", + project_id, + draft_id, + ) + .map_err(|error| format!("reconciliation-required: {error}"))? + else { + return Err("reconciliation-required: 素材画布草稿损坏且恢复副本不存在".to_string()); + }; + let sha_bytes = + read_asset_canvas_bytes(root, &recovery_sha_relative, "素材画布草稿恢复摘要", 128) + .map_err(|error| format!("reconciliation-required: {error}"))? + .ok_or_else(|| "reconciliation-required: 素材画布草稿恢复摘要不存在".to_string())?; + let expected_sha = std::str::from_utf8(&sha_bytes) + .map_err(|_| "reconciliation-required: 素材画布草稿恢复摘要不是 UTF-8".to_string())? + .trim(); + if expected_sha.len() != 64 + || !expected_sha + .bytes() + .all(|value| value.is_ascii_digit() || (b'a'..=b'f').contains(&value)) + || asset_canvas_sha256(&recovery_bytes) != expected_sha + { + return Err("reconciliation-required: 素材画布草稿恢复摘要不匹配".to_string()); + } + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative, + "素材画布草稿", + &recovered, + ASSET_CANVAS_MAX_DRAFT_BYTES, + )?; + Ok(Some(recovered)) +} + +fn write_asset_canvas_draft_locked(root: &Path, draft: &AssetCanvasDraft) -> Result<(), String> { + validate_asset_canvas_draft(draft, &draft.project_id, &draft.draft_id)?; + let manifest = current_asset_canvas_manifest(root)?; + validate_asset_canvas_draft_media(root, &manifest, draft)?; + let relative = asset_canvas_draft_relative_path(&draft.draft_id); + if let Some((current, _)) = read_asset_canvas_draft_candidate( + root, + &relative, + "素材画布草稿", + &draft.project_id, + &draft.draft_id, + )? { + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &asset_canvas_recovery_relative_path(&draft.draft_id), + "素材画布草稿恢复副本", + ¤t, + ASSET_CANVAS_MAX_DRAFT_BYTES, + )?; + let recovery_sha = format!( + "{}\n", + asset_canvas_sha256(&asset_canvas_json_bytes(¤t)?) + ); + write_asset_canvas_raw_sidecar( + root, + &asset_canvas_recovery_sha_relative_path(&draft.draft_id), + "素材画布草稿恢复摘要", + recovery_sha.as_bytes(), + 128, + )?; + } + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative, + "素材画布草稿", + draft, + ASSET_CANVAS_MAX_DRAFT_BYTES, + )?; + let installed = read_asset_canvas_draft_locked(root, &draft.project_id, &draft.draft_id)? + .ok_or_else(|| "素材画布草稿安装后缺失".to_string())?; + if installed != *draft { + return Err("素材画布草稿安装后回读不一致".to_string()); + } + Ok(()) +} + +#[cfg(unix)] +fn try_acquire_asset_canvas_draft_lock( + root: &Path, +) -> Result, String> { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + + validate_project_root(root)?; + let relative = normalize_relative_path(ASSET_CANVAS_DRAFT_LOCK)?; + let mut parts = relative.split('/').collect::>(); + let file_name = parts + .pop() + .ok_or_else(|| "素材画布锁路径无效".to_string())?; + let mut directory = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(root) + .map_err(|_| "安全打开项目目录失败".to_string())?; + for part in parts { + let component = CString::new(part).map_err(|_| "素材画布锁目录无效".to_string())?; + let created = unsafe { libc::mkdirat(directory.as_raw_fd(), component.as_ptr(), 0o700) }; + if created != 0 + && std::io::Error::last_os_error().kind() != std::io::ErrorKind::AlreadyExists + { + return Err("创建素材画布锁目录失败".to_string()); + } + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + component.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + return Err("素材画布锁目录必须是普通目录".to_string()); + } + directory = unsafe { File::from_raw_fd(fd) }; + } + let file_name = CString::new(file_name).map_err(|_| "素材画布锁文件名无效".to_string())?; + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + file_name.as_ptr(), + libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW, + 0o600, + ) + }; + if fd < 0 { + return Err("安全打开素材画布锁失败".to_string()); + } + let file = unsafe { File::from_raw_fd(fd) }; + let metadata = file + .metadata() + .map_err(|_| "读取素材画布锁元数据失败".to_string())?; + let uid = unsafe { libc::geteuid() }; + if !metadata.is_file() || metadata.uid() != uid || metadata.nlink() != 1 { + return Err("素材画布锁必须是当前用户持有的无硬链接普通文件".to_string()); + } + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| "收紧素材画布锁权限失败".to_string())?; + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + Ok(Some(AssetCanvasDraftLock { _file: file })) + } else if std::io::Error::last_os_error().kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err("获取素材画布系统文件锁失败".to_string()) + } +} + +#[cfg(windows)] +fn try_acquire_asset_canvas_draft_lock( + root: &Path, +) -> Result, String> { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + validate_project_root(root)?; + let path = resolve_local_project_path(root, ASSET_CANVAS_DRAFT_LOCK)?; + let parent = path + .parent() + .ok_or_else(|| "素材画布锁缺少父目录".to_string())?; + fs::create_dir_all(parent).map_err(|_| "创建素材画布锁目录失败".to_string())?; + match fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&path) + { + Ok(file) => { + validate_windows_regular_file_handle(&file, "素材画布锁")?; + crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; + Ok(Some(AssetCanvasDraftLock { _file: file })) + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) => + { + Ok(None) + } + Err(_) => Err("获取素材画布系统文件锁失败".to_string()), + } +} + +#[cfg(not(any(unix, windows)))] +fn try_acquire_asset_canvas_draft_lock( + _root: &Path, +) -> Result, String> { + Err("当前平台不支持素材画布系统文件锁".to_string()) +} + +fn acquire_asset_canvas_draft_lock(root: &Path) -> Result { + for attempt in 0..ASSET_CANVAS_LOCK_WAIT_ATTEMPTS { + if let Some(lock) = try_acquire_asset_canvas_draft_lock(root)? { + return Ok(lock); + } + if attempt + 1 < ASSET_CANVAS_LOCK_WAIT_ATTEMPTS { + std::thread::sleep(Duration::from_millis(ASSET_CANVAS_LOCK_WAIT_MILLIS)); + } + } + Err("素材画布正在被其他窗口保存,请稍后重试".to_string()) +} + +fn open_and_validate_image_file( + path: &Path, + expected_media_type: &str, + expected_sha256: Option<&str>, +) -> Result<(Vec, u32, u32), String> { + let (mut file, metadata) = open_project_snapshot_regular_file(path, "素材图片")?; + if metadata.len() == 0 || metadata.len() > ASSET_CANVAS_MAX_MEDIA_BYTES as u64 { + return Err("素材图片大小超限".to_string()); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take((ASSET_CANVAS_MAX_MEDIA_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| "读取素材图片失败".to_string())?; + if bytes.len() > ASSET_CANVAS_MAX_MEDIA_BYTES { + return Err("素材图片大小超限".to_string()); + } + let (_, width, height) = validate_image_bytes(expected_media_type, &bytes)?; + if expected_sha256.is_some_and(|expected| asset_canvas_sha256(&bytes) != expected) { + return Err("素材图片摘要不匹配".to_string()); + } + Ok((bytes, width, height)) +} + +fn project_asset_image<'a>( + root: &Path, + manifest: &'a GameCreationAppManifest, + asset_id: &str, +) -> Result<(&'a GameCreationAppAssetManifestEntry, Vec, u32, u32), String> { + let mut matches = manifest.assets.iter().filter(|asset| asset.id == asset_id); + let asset = matches + .next() + .ok_or_else(|| "精修源资源不存在".to_string())?; + if matches.next().is_some() + || !matches!( + asset.media_type.as_str(), + "image/png" | "image/jpeg" | "image/webp" + ) + { + return Err("精修源资源必须是唯一登记的 PNG、JPEG 或 WebP 图片".to_string()); + } + let path = resolve_local_project_path(root, &asset.local_path)?; + let (bytes, width, height) = open_and_validate_image_file(&path, &asset.media_type, None)?; + Ok((asset, bytes, width, height)) +} + +fn asset_canvas_source_resource_id(asset: &GameCreationAppAssetManifestEntry) -> String { + asset + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("local-asset:{}", asset.id)) +} + +fn default_asset_canvas_state() -> AssetCanvasState { + AssetCanvasState { + viewport: AssetCanvasViewport { + x: 0.0, + y: 0.0, + scale: 0.5, + }, + background_color: "#f4f4f5".to_string(), + layers: Vec::new(), + selected_layer_ids: Vec::new(), + primary_selected_layer_id: None, + } +} + +pub(crate) fn create_asset_canvas_draft_at( + root: &Path, + input: &CreateAssetCanvasDraftInput, +) -> Result { + validate_uuid_v4(&input.draft_id, "draftId")?; + let preflight = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + if let Some(existing) = + read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + { + if existing.intent != input.intent || existing.source_asset_id != input.source_asset_id { + return Err("draftId 已绑定到不同的素材画布请求".to_string()); + } + return Ok(CreateAssetCanvasDraftResult { + status: CreateAssetCanvasDraftStatus::Existing, + draft: existing, + }); + } + if preflight.project_id != manifest.project_id { + return Err("项目身份在素材画布锁获取期间发生变化".to_string()); + } + let now = asset_canvas_now(); + let (source_resource_id, canvas) = match input.intent { + AssetCanvasIntent::Create => { + if input.source_asset_id.is_some() { + return Err("create 草稿不能指定 sourceAssetId".to_string()); + } + (None, default_asset_canvas_state()) + } + AssetCanvasIntent::Refine => { + let source_asset_id = input + .source_asset_id + .as_deref() + .ok_or_else(|| "refine 草稿必须指定 sourceAssetId".to_string())?; + let (asset, _bytes, width, height) = + project_asset_image(root, &manifest, source_asset_id)?; + let resource_id = asset_canvas_source_resource_id(asset); + let layer_id = Uuid::new_v4().to_string(); + let mut canvas = default_asset_canvas_state(); + canvas.layers.push(AssetCanvasLayer { + layer_id: layer_id.clone(), + resource_id: resource_id.clone(), + title: asset.id.clone(), + media_ref: AssetCanvasMediaRef::ProjectAsset { + asset_id: asset.id.clone(), + }, + x: 6000.0 - f64::from(width) / 2.0, + y: 6000.0 - f64::from(height) / 2.0, + width: f64::from(width), + height: f64::from(height), + original_width: f64::from(width), + original_height: f64::from(height), + z_index: 0, + group_id: None, + hidden: false, + locked: false, + flip_x: false, + flip_y: false, + }); + canvas.selected_layer_ids.push(layer_id.clone()); + canvas.primary_selected_layer_id = Some(layer_id); + (Some(resource_id), canvas) + } + }; + let draft = AssetCanvasDraft { + schema_version: ASSET_CANVAS_SCHEMA_VERSION.to_string(), + draft_id: input.draft_id.clone(), + project_id: manifest.project_id, + intent: input.intent.clone(), + source_asset_id: input.source_asset_id.clone(), + source_resource_id, + revision: 0, + status: AssetCanvasDraftStatus::Editing, + canvas, + generations: Vec::new(), + pending_commit: None, + last_commit: None, + created_at: now, + updated_at: now, + }; + write_asset_canvas_draft_locked(root, &draft)?; + Ok(CreateAssetCanvasDraftResult { + status: CreateAssetCanvasDraftStatus::Created, + draft, + }) +} + +pub(crate) fn read_asset_canvas_draft_at( + root: &Path, + input: &ReadAssetCanvasDraftInput, +) -> Result { + validate_uuid_v4(&input.draft_id, "draftId")?; + let manifest = match validate_asset_canvas_project_identity(root, &input.expected_project_id) { + Ok(manifest) => manifest, + Err(error) if error.contains("expectedProjectId") => { + return Ok(ReadAssetCanvasDraftResult { + status: ReadAssetCanvasDraftStatus::ProjectIdentityConflict, + draft: None, + }); + } + Err(error) => return Err(error), + }; + let _lock = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, &manifest.project_id)?; + let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)?; + Ok(ReadAssetCanvasDraftResult { + status: if draft.is_some() { + ReadAssetCanvasDraftStatus::Found + } else { + ReadAssetCanvasDraftStatus::NotFound + }, + draft, + }) +} + +pub(crate) fn update_asset_canvas_draft_at( + root: &Path, + input: &UpdateAssetCanvasDraftInput, +) -> Result { + validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; + if !matches!( + input.status, + AssetCanvasDraftStatus::Editing + | AssetCanvasDraftStatus::Generating + | AssetCanvasDraftStatus::Cancelled + ) { + return Err("前端不能推进素材画布事务终态".to_string()); + } + validate_asset_canvas_state(&input.canvas)?; + validate_generation_records(&input.generations)?; + validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + + if draft.revision != input.expected_draft_revision { + return Ok(UpdateAssetCanvasDraftResult { + status: UpdateAssetCanvasDraftStatus::Conflict, + draft, + }); + } + if matches!( + draft.status, + AssetCanvasDraftStatus::CommitPrepared + | AssetCanvasDraftStatus::Committed + | AssetCanvasDraftStatus::ReconciliationRequired + ) { + return Err("素材画布草稿正在提交或已进入事务终态".to_string()); + } + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; + draft.status = input.status.clone(); + draft.canvas = input.canvas.clone(); + draft.generations = input.generations.clone(); + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + Ok(UpdateAssetCanvasDraftResult { + status: UpdateAssetCanvasDraftStatus::Updated, + draft, + }) +} + +fn install_new_asset_canvas_file(path: &Path, bytes: &[u8], label: &str) -> Result<(), String> { + let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; + fs::create_dir_all(parent).map_err(|_| format!("创建 {label} 目录失败"))?; + if fs::symlink_metadata(path).is_ok() { + return Err(format!("{label} 目标已存在")); + } + let temp_path = path.with_file_name(format!(".asset-canvas-{}.tmp", Uuid::new_v4())); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let mut file = options + .open(&temp_path) + .map_err(|_| format!("创建 {label} 临时文件失败"))?; + if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) { + let _ = fs::remove_file(&temp_path); + return Err(format!("写入 {label} 临时文件失败:{error}")); + } + drop(file); + fs::hard_link(&temp_path, path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!("原子安装 {label} 失败:{error}") + })?; + fs::remove_file(&temp_path).map_err(|error| format!("完成 {label} 安装失败:{error}"))?; + let (_, metadata) = open_project_snapshot_regular_file(path, label)?; + if metadata.len() != bytes.len() as u64 { + return Err(format!("{label} 安装后大小不一致")); + } + #[cfg(unix)] + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("同步 {label} 目录失败:{error}"))?; + Ok(()) +} + +fn asset_canvas_draft_media_bytes(root: &Path, draft_id: &str) -> Result { + let directory = + resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/media/{draft_id}"))?; + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(_) => return Err("读取素材画布媒体目录失败".to_string()), + }; + let mut total = 0_u64; + let mut count = 0_usize; + for entry in entries { + let entry = entry.map_err(|_| "读取素材画布媒体条目失败".to_string())?; + count += 1; + if count > ASSET_CANVAS_MAX_LAYERS * 2 { + return Err("素材画布媒体条目数量超限".to_string()); + } + let (_, metadata) = open_project_snapshot_regular_file(&entry.path(), "素材画布媒体")?; + total = total + .checked_add(metadata.len()) + .ok_or_else(|| "素材画布媒体总量溢出".to_string())?; + if total > ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES { + return Err("单个素材画布草稿媒体总量超过 512 MiB".to_string()); + } + } + Ok(total) +} + +pub(crate) fn store_asset_canvas_media_at( + root: &Path, + input: &StoreAssetCanvasMediaInput, +) -> Result { + validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; + let (media_type, width, height) = validate_image_bytes(&input.media_type, &input.bytes)?; + validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.revision != input.expected_draft_revision { + return Ok(StoreAssetCanvasMediaResult { + status: "conflict".to_string(), + draft_id: input.draft_id.clone(), + draft_revision: draft.revision, + media_ref: None, + draft: Some(draft), + }); + } + if asset_canvas_draft_media_bytes(root, &input.draft_id)? + .checked_add(input.bytes.len() as u64) + .is_none_or(|total| total > ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES) + { + return Err("单个素材画布草稿媒体总量超过 512 MiB".to_string()); + } + let media_id = new_asset_canvas_token()?; + let extension = media_extension(&media_type)?; + let relative = format!( + "{ASSET_CANVAS_ROOT}/media/{}/{media_id}.{extension}", + input.draft_id + ); + let path = resolve_local_project_path(root, &relative)?; + install_new_asset_canvas_file(&path, &input.bytes, "素材画布媒体")?; + let media_ref = AssetCanvasMediaRef::DraftMedia { + media_id, + media_type, + sha256: asset_canvas_sha256(&input.bytes), + byte_length: input.bytes.len() as u64, + pixel_width: width, + pixel_height: height, + }; + Ok(StoreAssetCanvasMediaResult { + status: "stored".to_string(), + draft_id: input.draft_id.clone(), + draft_revision: draft.revision, + media_ref: Some(media_ref), + draft: None, + }) +} + +pub(crate) fn stage_asset_canvas_image_at( + root: &Path, + input: &StageAssetCanvasImageInput, +) -> Result { + validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; + let (media_type, width, height) = validate_image_bytes(&input.media_type, &input.bytes)?; + validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.revision != input.expected_draft_revision { + return Ok(StageAssetCanvasImageResult { + status: "conflict".to_string(), + staged_image_token: None, + draft_id: input.draft_id.clone(), + draft_revision: draft.revision, + media_type: None, + sha256: None, + byte_length: None, + pixel_width: None, + pixel_height: None, + expires_at: None, + draft: Some(draft), + }); + } + let token = new_asset_canvas_token()?; + let extension = media_extension(&media_type)?; + let image_relative = format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{extension}"); + install_new_asset_canvas_file( + &resolve_local_project_path(root, &image_relative)?, + &input.bytes, + "素材画布 staging 图片", + )?; + let expires_at = asset_canvas_now() + .saturating_add(ASSET_CANVAS_STAGING_TTL_MILLIS) + .min(ASSET_CANVAS_MAX_SAFE_INTEGER); + let metadata = AssetCanvasStagedImage { + schema_version: "game-creator-asset-canvas-staging.v1".to_string(), + project_id: manifest.project_id, + draft_id: input.draft_id.clone(), + draft_revision: draft.revision, + staged_image_token: token.clone(), + media_type: media_type.clone(), + sha256: asset_canvas_sha256(&input.bytes), + byte_length: input.bytes.len() as u64, + pixel_width: width, + pixel_height: height, + expires_at, + }; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), + "素材画布 staging 元数据", + &metadata, + 16 * 1024, + )?; + Ok(StageAssetCanvasImageResult { + status: "staged".to_string(), + staged_image_token: Some(token), + draft_id: input.draft_id.clone(), + draft_revision: draft.revision, + media_type: Some(media_type), + sha256: Some(metadata.sha256), + byte_length: Some(metadata.byte_length), + pixel_width: Some(width), + pixel_height: Some(height), + expires_at: Some(expires_at), + draft: None, + }) +} + +fn draft_media_relative_path( + draft_id: &str, + media_ref: &AssetCanvasMediaRef, +) -> Result<(String, String, Option), String> { + match media_ref { + AssetCanvasMediaRef::DraftMedia { + media_id, + media_type, + sha256, + .. + } => { + validate_plain_component(media_id, "mediaId", 128)?; + let extension = media_extension(media_type)?; + Ok(( + format!("{ASSET_CANVAS_ROOT}/media/{draft_id}/{media_id}.{extension}"), + media_type.clone(), + Some(sha256.clone()), + )) + } + AssetCanvasMediaRef::ProjectAsset { .. } => { + Err("project asset 需要通过 manifest 解析".to_string()) + } + } +} + +pub(crate) fn read_asset_canvas_media_at( + root: &Path, + input: &ReadAssetCanvasMediaInput, +) -> Result { + validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if !draft + .canvas + .layers + .iter() + .any(|layer| layer.media_ref == input.media_ref) + { + return Err("媒体引用不属于当前草稿".to_string()); + } + match &input.media_ref { + AssetCanvasMediaRef::ProjectAsset { asset_id } => { + let (asset, bytes, _, _) = project_asset_image(root, &manifest, asset_id)?; + Ok(ReadAssetCanvasMediaResult { + media_type: asset.media_type.clone(), + bytes, + }) + } + media_ref => { + let (relative, media_type, sha256) = + draft_media_relative_path(&input.draft_id, media_ref)?; + let (bytes, _, _) = open_and_validate_image_file( + &resolve_local_project_path(root, &relative)?, + &media_type, + sha256.as_deref(), + )?; + Ok(ReadAssetCanvasMediaResult { media_type, bytes }) + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AssetCanvasGenerationProvenance { + pub(crate) task_id: String, + pub(crate) prompt: String, + pub(crate) model: String, + pub(crate) generation_route: String, + pub(crate) generation_kind: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct CommitAssetCanvasInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_revision: u64, + pub(crate) expected_draft_revision: u64, + pub(crate) draft_id: String, + pub(crate) commit_id: String, + pub(crate) idempotency_key: String, + pub(crate) intent: AssetCanvasIntent, + pub(crate) source_asset_id: Option, + pub(crate) staged_image_token: String, + pub(crate) name: String, + pub(crate) asset_kind: String, + pub(crate) reference_resource_ids: Vec, + pub(crate) generation_provenance: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum AssetCanvasConflictKind { + ProjectIdentity, + ProjectRevision, + DraftRevision, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "status", rename_all = "kebab-case")] +pub(crate) enum CommitAssetCanvasResult { + Committed { + #[serde(rename = "projectId")] + project_id: String, + #[serde(rename = "projectRevision")] + project_revision: u64, + #[serde(rename = "committedProjectRevision")] + committed_project_revision: u64, + #[serde(rename = "draftId")] + draft_id: String, + #[serde(rename = "draftRevision")] + draft_revision: u64, + #[serde(rename = "commitId")] + commit_id: String, + #[serde(rename = "idempotencyKey")] + idempotency_key: String, + #[serde(rename = "eventId")] + event_id: String, + asset: GameCreationAppAssetManifestEntry, + manifest: GameCreationAppManifest, + }, + AlreadyCommitted { + #[serde(rename = "projectId")] + project_id: String, + #[serde(rename = "projectRevision")] + project_revision: u64, + #[serde(rename = "committedProjectRevision")] + committed_project_revision: u64, + #[serde(rename = "draftId")] + draft_id: String, + #[serde(rename = "draftRevision")] + draft_revision: u64, + #[serde(rename = "commitId")] + commit_id: String, + #[serde(rename = "idempotencyKey")] + idempotency_key: String, + #[serde(rename = "eventId")] + event_id: String, + asset: GameCreationAppAssetManifestEntry, + manifest: GameCreationAppManifest, + }, + Conflict { + #[serde(rename = "conflictKind")] + conflict_kind: AssetCanvasConflictKind, + #[serde(rename = "expectedProjectId")] + expected_project_id: String, + #[serde(rename = "projectId")] + project_id: Option, + #[serde(rename = "expectedRevision")] + expected_revision: u64, + #[serde(rename = "projectRevision")] + project_revision: Option, + #[serde(rename = "expectedDraftRevision")] + expected_draft_revision: u64, + #[serde(rename = "draftRevision")] + draft_revision: Option, + #[serde(rename = "commitId")] + commit_id: String, + #[serde(rename = "idempotencyKey")] + idempotency_key: String, + asset: Option, + manifest: Option, + }, + RolledBack { + #[serde(rename = "projectId")] + project_id: String, + #[serde(rename = "projectRevision")] + project_revision: u64, + #[serde(rename = "draftId")] + draft_id: String, + #[serde(rename = "draftRevision")] + draft_revision: u64, + #[serde(rename = "commitId")] + commit_id: String, + #[serde(rename = "idempotencyKey")] + idempotency_key: String, + #[serde(rename = "eventId")] + event_id: String, + asset: Option, + manifest: GameCreationAppManifest, + }, + ReconciliationRequired { + #[serde(rename = "projectId")] + project_id: String, + #[serde(rename = "projectRevision")] + project_revision: u64, + #[serde(rename = "draftId")] + draft_id: String, + #[serde(rename = "draftRevision")] + draft_revision: u64, + #[serde(rename = "commitId")] + commit_id: String, + #[serde(rename = "idempotencyKey")] + idempotency_key: String, + #[serde(rename = "eventId")] + event_id: String, + asset: Option, + manifest: GameCreationAppManifest, + }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AssetCanvasCommittedEventBody { + schema_version: String, + event_id: String, + project_id: String, + committed_project_revision: u64, + draft_id: String, + commit_id: String, + idempotency_key: String, + asset: GameCreationAppAssetManifestEntry, + manifest: GameCreationAppManifest, + occurred_at: u64, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AssetCanvasCommittedEvent { + pub(crate) schema_version: String, + pub(crate) event_id: String, + pub(crate) project_path: String, + pub(crate) project_id: String, + pub(crate) committed_project_revision: u64, + pub(crate) draft_id: String, + pub(crate) commit_id: String, + pub(crate) idempotency_key: String, + pub(crate) asset: GameCreationAppAssetManifestEntry, + pub(crate) manifest: GameCreationAppManifest, + pub(crate) occurred_at: u64, +} + +impl AssetCanvasCommittedEventBody { + fn with_project_path(&self, root: &Path) -> AssetCanvasCommittedEvent { + AssetCanvasCommittedEvent { + schema_version: self.schema_version.clone(), + event_id: self.event_id.clone(), + project_path: root.to_string_lossy().into_owned(), + project_id: self.project_id.clone(), + committed_project_revision: self.committed_project_revision, + draft_id: self.draft_id.clone(), + commit_id: self.commit_id.clone(), + idempotency_key: self.idempotency_key.clone(), + asset: self.asset.clone(), + manifest: self.manifest.clone(), + occurred_at: self.occurred_at, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum AssetCanvasLedgerStatus { + Prepared, + Committed, + RolledBack, + ReconciliationRequired, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum AssetCanvasEventDelivery { + Pending, + Attempted, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AssetCanvasCommitLedger { + schema_version: String, + project_id: String, + draft_id: String, + commit_id: String, + idempotency_key: String, + request_fingerprint: String, + status: AssetCanvasLedgerStatus, + expected_project_revision: u64, + committed_project_revision: Option, + expected_draft_revision: u64, + committed_draft_revision: Option, + asset_id: Option, + event_id: String, + event_payload: Option, + event_delivery: AssetCanvasEventDelivery, + created_at: u64, + updated_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum AssetCanvasTransactionStage { + Prepared, + FileInstalled, + ManifestInstalled, + RevisionInstalled, + Verified, + Committed, + EventAttempted, + RolledBack, + ReconciliationRequired, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AssetCanvasTransactionJournal { + schema_version: String, + project_id: String, + draft_id: String, + commit_id: String, + idempotency_key: String, + request_fingerprint: String, + stage: AssetCanvasTransactionStage, + expected_project_revision: u64, + target_project_revision: u64, + expected_draft_revision: u64, + target_draft_revision: u64, + staged_image: AssetCanvasStagedImage, + final_image_relative_path: String, + final_image_sha256: String, + final_image_existed_before: bool, + manifest_before_sha256: String, + manifest_after_sha256: String, + project_revision_before_sha256: Option, + project_revision_after_sha256: String, + asset_id: String, + event_id: String, + occurred_at: u64, + created_at: u64, + updated_at: u64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct NormalizedAssetCanvasCommitRequest<'a> { + expected_project_id: &'a str, + expected_revision: u64, + expected_draft_revision: u64, + draft_id: &'a str, + commit_id: &'a str, + idempotency_key: &'a str, + intent: &'a AssetCanvasIntent, + source_asset_id: &'a Option, + staged_image_token: &'a str, + name: &'a str, + asset_kind: &'a str, + reference_resource_ids: &'a [String], + generation_provenance: &'a Option, +} + +#[derive(Debug)] +pub(crate) struct CommitAssetCanvasExecution { + pub(crate) result: CommitAssetCanvasResult, + pub(crate) event: Option, +} + +fn asset_canvas_commit_relative_path(commit_id: &str) -> String { + format!("{ASSET_CANVAS_ROOT}/commits/{commit_id}.json") +} + +fn asset_canvas_transaction_relative_path(commit_id: &str, file: &str) -> String { + format!("{ASSET_CANVAS_ROOT}/transactions/{commit_id}/{file}") +} + +fn validate_asset_canvas_name(name: &str) -> Result { + let name = name.nfc().collect::(); + let upper = name.trim_end_matches([' ', '.']).to_ascii_uppercase(); + let stem = upper.split('.').next().unwrap_or_default(); + const WINDOWS_RESERVED: [&str; 22] = [ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + ]; + if name.is_empty() + || name.chars().count() > 80 + || name + .chars() + .any(|value| value.is_control() || "/\\:*?\"<>|".contains(value)) + || matches!(name.as_str(), "." | "..") + || name.ends_with([' ', '.']) + || WINDOWS_RESERVED.contains(&stem) + { + return Err("素材名称无效".to_string()); + } + Ok(name) +} + +fn normalize_asset_canvas_references(values: &[String]) -> Result, String> { + if values.len() > ASSET_CANVAS_MAX_REFERENCES { + return Err(format!( + "referenceResourceIds 最多支持 {ASSET_CANVAS_MAX_REFERENCES} 项" + )); + } + let mut normalized = BTreeSet::new(); + for value in values { + let value = value.trim(); + if value.is_empty() || value.chars().count() > 512 || value.chars().any(char::is_control) { + return Err("referenceResourceIds 包含无效资源身份".to_string()); + } + normalized.insert(value.to_string()); + } + Ok(normalized.into_iter().collect()) +} + +fn validate_asset_kind(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() + || value.len() > 64 + || !value.as_bytes()[0].is_ascii_lowercase() && !value.as_bytes()[0].is_ascii_digit() + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) + { + return Err("assetKind 无效".to_string()); + } + Ok(value.to_string()) +} + +fn asset_canvas_json_bytes(value: &T) -> Result, String> { + let mut bytes = + serde_json::to_vec_pretty(value).map_err(|error| format!("序列化事务文件失败:{error}"))?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn read_asset_canvas_ledger( + root: &Path, + commit_id: &str, +) -> Result, String> { + read_agent_runtime_json_sidecar_with_max_bytes( + root, + &asset_canvas_commit_relative_path(commit_id), + "素材画布 commit ledger", + ASSET_CANVAS_MAX_LEDGER_BYTES, + ) +} + +fn write_asset_canvas_ledger(root: &Path, ledger: &AssetCanvasCommitLedger) -> Result<(), String> { + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &asset_canvas_commit_relative_path(&ledger.commit_id), + "素材画布 commit ledger", + ledger, + ASSET_CANVAS_MAX_LEDGER_BYTES, + ) +} + +fn read_asset_canvas_journal( + root: &Path, + commit_id: &str, +) -> Result, String> { + read_agent_runtime_json_sidecar_with_max_bytes( + root, + &asset_canvas_transaction_relative_path(commit_id, "journal.json"), + "素材画布 transaction journal", + ASSET_CANVAS_MAX_TRANSACTION_BYTES, + ) +} + +fn write_asset_canvas_journal( + root: &Path, + journal: &AssetCanvasTransactionJournal, +) -> Result<(), String> { + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &asset_canvas_transaction_relative_path(&journal.commit_id, "journal.json"), + "素材画布 transaction journal", + journal, + ASSET_CANVAS_MAX_TRANSACTION_BYTES, + ) +} + +fn find_ledger_by_idempotency_key( + root: &Path, + key: &str, +) -> Result, String> { + let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/commits"))?; + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err("读取素材画布 commit ledger 目录失败".to_string()), + }; + let mut count = 0usize; + for entry in entries { + let entry = entry.map_err(|_| "读取素材画布 commit ledger 失败".to_string())?; + count += 1; + if count > 4096 { + return Err("素材画布 commit ledger 数量超限".to_string()); + } + let path = entry.path(); + let (_, metadata) = open_project_snapshot_regular_file(&path, "素材画布 commit ledger")?; + if metadata.len() > ASSET_CANVAS_MAX_LEDGER_BYTES as u64 { + return Err("素材画布 commit ledger 超限".to_string()); + } + let content = + fs::read_to_string(&path).map_err(|_| "读取素材画布 commit ledger 失败".to_string())?; + let ledger: AssetCanvasCommitLedger = serde_json::from_str(&content) + .map_err(|_| "解析素材画布 commit ledger 失败".to_string())?; + if ledger.idempotency_key == key { + return Ok(Some(ledger)); + } + } + Ok(None) +} + +fn read_staged_image_locked( + root: &Path, + token: &str, +) -> Result<(AssetCanvasStagedImage, Vec), String> { + validate_plain_component(token, "stagedImageToken", 128)?; + let metadata = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), + "素材画布 staging 元数据", + 16 * 1024, + )? + .ok_or_else(|| "素材画布 staging 元数据不存在".to_string())?; + if metadata.staged_image_token != token + || metadata.schema_version != "game-creator-asset-canvas-staging.v1" + { + return Err("素材画布 staging 身份无效".to_string()); + } + let extension = media_extension(&metadata.media_type)?; + let path = resolve_local_project_path( + root, + &format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{extension}"), + )?; + let (bytes, width, height) = + open_and_validate_image_file(&path, &metadata.media_type, Some(&metadata.sha256))?; + if bytes.len() as u64 != metadata.byte_length + || width != metadata.pixel_width + || height != metadata.pixel_height + { + return Err("素材画布 staging 文件与元数据不匹配".to_string()); + } + Ok((metadata, bytes)) +} + +fn committed_result_from_ledger( + root: &Path, + ledger: &AssetCanvasCommitLedger, + manifest: GameCreationAppManifest, + project_revision: u64, +) -> Result { + let asset_id = ledger + .asset_id + .as_deref() + .ok_or_else(|| "已提交 ledger 缺少 assetId".to_string())?; + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == asset_id) + .cloned() + .ok_or_else(|| "已提交资产不在当前 manifest 中".to_string())?; + let committed_project_revision = ledger + .committed_project_revision + .ok_or_else(|| "已提交 ledger 缺少 committedProjectRevision".to_string())?; + let draft_revision = ledger + .committed_draft_revision + .ok_or_else(|| "已提交 ledger 缺少 committedDraftRevision".to_string())?; + let event_body = ledger + .event_payload + .clone() + .ok_or_else(|| "已提交 ledger 缺少事件 payload".to_string())?; + Ok(CommitAssetCanvasExecution { + result: CommitAssetCanvasResult::AlreadyCommitted { + project_id: ledger.project_id.clone(), + project_revision, + committed_project_revision, + draft_id: ledger.draft_id.clone(), + draft_revision, + commit_id: ledger.commit_id.clone(), + idempotency_key: ledger.idempotency_key.clone(), + event_id: ledger.event_id.clone(), + asset, + manifest, + }, + event: Some(event_body.with_project_path(root)), + }) +} + +fn asset_canvas_conflict( + input: &CommitAssetCanvasInput, + kind: AssetCanvasConflictKind, + project_id: Option, + project_revision: Option, + draft_revision: Option, + manifest: Option, +) -> CommitAssetCanvasExecution { + CommitAssetCanvasExecution { + result: CommitAssetCanvasResult::Conflict { + conflict_kind: kind, + expected_project_id: input.expected_project_id.clone(), + project_id, + expected_revision: input.expected_revision, + project_revision, + expected_draft_revision: input.expected_draft_revision, + draft_revision, + commit_id: input.commit_id.clone(), + idempotency_key: input.idempotency_key.clone(), + asset: None, + manifest, + }, + event: None, + } +} + +fn verify_committed_asset_canvas_state( + root: &Path, + journal: &AssetCanvasTransactionJournal, + expected_manifest: &GameCreationAppManifest, + expected_revision: &AgentRuntimeProjectRevision, +) -> Result { + let final_path = resolve_local_project_path(root, &journal.final_image_relative_path)?; + let (bytes, width, height) = open_and_validate_image_file( + &final_path, + &journal.staged_image.media_type, + Some(&journal.final_image_sha256), + )?; + if bytes.len() as u64 != journal.staged_image.byte_length + || width != journal.staged_image.pixel_width + || height != journal.staged_image.pixel_height + { + return Err("正式素材文件与 staging 身份不一致".to_string()); + } + let manifest = current_asset_canvas_manifest(root)?; + if manifest != *expected_manifest + || asset_canvas_sha256(&asset_canvas_json_bytes(&manifest)?) + != journal.manifest_after_sha256 + { + return Err("manifest 安装后回读不一致".to_string()); + } + let revision = read_game_creator_agent_runtime_project_revision(root)?; + if revision != *expected_revision + || revision.revision != journal.target_project_revision + || asset_canvas_sha256(&asset_canvas_json_bytes(&revision)?) + != journal.project_revision_after_sha256 + { + return Err("项目 revision 安装后回读不一致".to_string()); + } + let mut assets = manifest + .assets + .iter() + .filter(|asset| asset.id == journal.asset_id); + let asset = assets + .next() + .cloned() + .ok_or_else(|| "正式资产未进入 manifest".to_string())?; + if assets.next().is_some() + || asset.local_path != journal.final_image_relative_path + || asset.media_type != journal.staged_image.media_type + || asset.source.resource_id.as_deref() != Some(&format!("local-asset:{}", journal.asset_id)) + { + return Err("正式资产 manifest 身份无效".to_string()); + } + Ok(asset) +} + +fn write_asset_canvas_transaction_snapshot( + root: &Path, + commit_id: &str, + file_name: &str, + bytes: &[u8], +) -> Result<(), String> { + if bytes.len() > ASSET_CANVAS_MAX_LEDGER_BYTES { + return Err("素材画布事务快照超限".to_string()); + } + let path = resolve_local_project_path( + root, + &asset_canvas_transaction_relative_path(commit_id, file_name), + )?; + install_new_asset_canvas_file(&path, bytes, "素材画布事务快照") +} + +fn validate_generation_provenance(value: &AssetCanvasGenerationProvenance) -> Result<(), String> { + for (label, field, max_chars) in [ + ("taskId", value.task_id.as_str(), 512usize), + ("prompt", value.prompt.as_str(), 32_000usize), + ("model", value.model.as_str(), 512usize), + ( + "generationRoute", + value.generation_route.as_str(), + 1024usize, + ), + ("generationKind", value.generation_kind.as_str(), 128usize), + ] { + if field.trim().is_empty() + || field.chars().count() > max_chars + || field.chars().any(char::is_control) + { + return Err(format!("generationProvenance.{label} 无效")); + } + } + Ok(()) +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AssetCanvasCommitFaultStage { + Prepared, + FileInstalled, + ManifestInstalled, + RevisionInstalled, + Verified, + LedgerCommitted, +} + +#[cfg(not(test))] +type AssetCanvasCommitFaultStage = (); + +fn maybe_fail_asset_canvas_commit( + fault: Option, + #[allow(unused_variables)] stage: &str, +) -> Result<(), String> { + #[cfg(test)] + if fault.is_some_and(|fault| { + matches!( + (fault, stage), + (AssetCanvasCommitFaultStage::Prepared, "prepared") + | (AssetCanvasCommitFaultStage::FileInstalled, "file-installed") + | ( + AssetCanvasCommitFaultStage::ManifestInstalled, + "manifest-installed" + ) + | ( + AssetCanvasCommitFaultStage::RevisionInstalled, + "revision-installed" + ) + | (AssetCanvasCommitFaultStage::Verified, "verified") + | ( + AssetCanvasCommitFaultStage::LedgerCommitted, + "ledger-committed" + ) + ) + }) { + return Err(format!("fault-injected:{stage}")); + } + Ok(()) +} + +fn commit_asset_canvas_at_internal( + root: &Path, + input: &CommitAssetCanvasInput, + fault: Option, +) -> Result { + validate_uuid_v4(&input.draft_id, "draftId")?; + validate_uuid_v4(&input.commit_id, "commitId")?; + validate_uuid_v4(&input.idempotency_key, "idempotencyKey")?; + validate_safe_revision(input.expected_revision, "expectedRevision")?; + validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; + let target_project_revision = input + .expected_revision + .checked_add(1) + .ok_or_else(|| "项目 revision 已达到上限".to_string())?; + let target_draft_revision = input + .expected_draft_revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(target_project_revision, "目标项目 revision")?; + validate_safe_revision(target_draft_revision, "目标草稿 revision")?; + let name = validate_asset_canvas_name(&input.name)?; + let asset_kind = validate_asset_kind(&input.asset_kind)?; + let mut references = normalize_asset_canvas_references(&input.reference_resource_ids)?; + if let Some(provenance) = input.generation_provenance.as_ref() { + validate_generation_provenance(provenance)?; + } + let preflight_manifest = + match validate_asset_canvas_project_identity(root, &input.expected_project_id) { + Ok(manifest) => manifest, + Err(error) if error.contains("expectedProjectId") => { + return Ok(asset_canvas_conflict( + input, + AssetCanvasConflictKind::ProjectIdentity, + None, + None, + None, + None, + )); + } + Err(error) => return Err(error), + }; + let preflight_revision = read_game_creator_agent_runtime_project_revision(root)?; + validate_safe_revision(preflight_revision.revision, "项目 revision")?; + + let _project_lock = acquire_project_write_lock(root, "asset-canvas.commit")?; + let _draft_lock = acquire_asset_canvas_draft_lock(root)?; + let mut manifest = + match validate_asset_canvas_project_identity(root, &input.expected_project_id) { + Ok(manifest) => manifest, + Err(error) if error.contains("expectedProjectId") => { + return Ok(asset_canvas_conflict( + input, + AssetCanvasConflictKind::ProjectIdentity, + None, + None, + None, + None, + )); + } + Err(error) => return Err(error), + }; + if preflight_manifest.project_id != manifest.project_id { + return Ok(asset_canvas_conflict( + input, + AssetCanvasConflictKind::ProjectIdentity, + None, + None, + None, + None, + )); + } + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + validate_safe_revision(current_revision.revision, "项目 revision")?; + let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + + if input.intent == AssetCanvasIntent::Refine { + let source_resource_id = draft + .source_resource_id + .as_deref() + .ok_or_else(|| "refine 草稿缺少源资源身份".to_string())?; + references.push(source_resource_id.to_string()); + references = normalize_asset_canvas_references(&references)?; + } + + let normalized_request = NormalizedAssetCanvasCommitRequest { + expected_project_id: input.expected_project_id.trim(), + expected_revision: input.expected_revision, + expected_draft_revision: input.expected_draft_revision, + draft_id: &input.draft_id, + commit_id: &input.commit_id, + idempotency_key: &input.idempotency_key, + intent: &input.intent, + source_asset_id: &input.source_asset_id, + staged_image_token: &input.staged_image_token, + name: &name, + asset_kind: &asset_kind, + reference_resource_ids: &references, + generation_provenance: &input.generation_provenance, + }; + let request_fingerprint = asset_canvas_sha256( + &serde_json::to_vec(&normalized_request) + .map_err(|error| format!("生成提交指纹失败:{error}"))?, + ); + let by_commit = read_asset_canvas_ledger(root, &input.commit_id)?; + let by_key = find_ledger_by_idempotency_key(root, &input.idempotency_key)?; + for ledger in [by_commit.as_ref(), by_key.as_ref()].into_iter().flatten() { + if ledger.commit_id != input.commit_id + || ledger.idempotency_key != input.idempotency_key + || ledger.request_fingerprint != request_fingerprint + { + return Err("commitId 或 idempotencyKey 已绑定到不同提交请求".to_string()); + } + if ledger.status == AssetCanvasLedgerStatus::Committed { + return committed_result_from_ledger(root, ledger, manifest, current_revision.revision); + } + if matches!( + ledger.status, + AssetCanvasLedgerStatus::Prepared | AssetCanvasLedgerStatus::ReconciliationRequired + ) { + return Err("素材画布存在未完成事务,必须先恢复或对账".to_string()); + } + } + if current_revision.revision != input.expected_revision { + return Ok(asset_canvas_conflict( + input, + AssetCanvasConflictKind::ProjectRevision, + Some(manifest.project_id.clone()), + Some(current_revision.revision), + Some(draft.revision), + Some(manifest), + )); + } + if draft.revision != input.expected_draft_revision { + return Ok(asset_canvas_conflict( + input, + AssetCanvasConflictKind::DraftRevision, + Some(manifest.project_id.clone()), + Some(current_revision.revision), + Some(draft.revision), + Some(manifest), + )); + } + if preflight_revision.revision != input.expected_revision + && current_revision.revision == input.expected_revision + { + return Err("项目 revision 在提交预检期间发生身份替换".to_string()); + } + if draft.intent != input.intent || draft.source_asset_id != input.source_asset_id { + return Err("提交 intent/sourceAssetId 与权威草稿不一致".to_string()); + } + if !matches!( + draft.status, + AssetCanvasDraftStatus::Editing | AssetCanvasDraftStatus::Generating + ) { + return Err("素材画布草稿当前状态不允许提交".to_string()); + } + let (staged, staged_bytes) = read_staged_image_locked(root, &input.staged_image_token)?; + if staged.project_id != manifest.project_id + || staged.draft_id != draft.draft_id + || staged.draft_revision != draft.revision + { + return Err("staging 图片与当前项目草稿 revision 不匹配".to_string()); + } + + let manifest_before = manifest.clone(); + if input.intent == AssetCanvasIntent::Refine { + let source_id = input + .source_asset_id + .as_deref() + .ok_or_else(|| "refine 提交缺少 sourceAssetId".to_string())?; + let source = manifest + .assets + .iter_mut() + .find(|asset| asset.id == source_id) + .ok_or_else(|| "refine 源资产不存在".to_string())?; + if !matches!( + source.media_type.as_str(), + "image/png" | "image/jpeg" | "image/webp" + ) { + return Err("refine 源资产不是受支持图片".to_string()); + } + let source_resource_id = asset_canvas_source_resource_id(source); + if draft.source_resource_id.as_deref() != Some(source_resource_id.as_str()) { + return Err("refine 源资源身份与草稿不一致".to_string()); + } + if source + .source + .resource_id + .as_deref() + .map(str::trim) + .is_some_and(|value| value != source_resource_id) + { + return Err("refine 源资源存在冲突 resourceId".to_string()); + } + source.source.resource_id = Some(source_resource_id.clone()); + references.push(source_resource_id); + references = normalize_asset_canvas_references(&references)?; + } else if input.source_asset_id.is_some() { + return Err("create 提交不能包含 sourceAssetId".to_string()); + } + + let asset_id = format!("canvas-{}", input.commit_id); + if manifest.assets.iter().any(|asset| asset.id == asset_id) { + return Err("目标 canvas asset 已存在但没有匹配 committed ledger".to_string()); + } + let extension = media_extension(&staged.media_type)?; + let final_relative_path = format!("assets/canvas/{name}--{}.{}", input.commit_id, extension); + let final_path = resolve_local_project_path(root, &final_relative_path)?; + if fs::symlink_metadata(&final_path).is_ok() { + return Err("正式素材目标路径已存在".to_string()); + } + let source = GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: Some(format!("local-asset:{asset_id}")), + asset_object_id: None, + task_id: input + .generation_provenance + .as_ref() + .map(|value| value.task_id.clone()), + prompt: input + .generation_provenance + .as_ref() + .map(|value| value.prompt.clone()), + model: input + .generation_provenance + .as_ref() + .map(|value| value.model.clone()), + generation_route: input + .generation_provenance + .as_ref() + .map(|value| value.generation_route.clone()), + generation_kind: input + .generation_provenance + .as_ref() + .map(|value| value.generation_kind.clone()), + reference_resource_ids: references, + }; + let asset = GameCreationAppAssetManifestEntry { + id: asset_id.clone(), + kind: asset_kind, + media_type: staged.media_type.clone(), + local_path: final_relative_path.clone(), + source, + }; + manifest.assets.push(asset.clone()); + + let mut target_revision_state = current_revision.clone(); + target_revision_state.revision = target_project_revision; + target_revision_state.updated_at = asset_canvas_now(); + let manifest_before_bytes = asset_canvas_json_bytes(&manifest_before)?; + let manifest_after_bytes = asset_canvas_json_bytes(&manifest)?; + let revision_before_bytes = asset_canvas_json_bytes(¤t_revision)?; + let revision_after_bytes = asset_canvas_json_bytes(&target_revision_state)?; + let occurred_at = asset_canvas_now(); + let event_id = Uuid::new_v4().to_string(); + let event_body = AssetCanvasCommittedEventBody { + schema_version: ASSET_CANVAS_EVENT_SCHEMA_VERSION.to_string(), + event_id: event_id.clone(), + project_id: manifest.project_id.clone(), + committed_project_revision: target_project_revision, + draft_id: draft.draft_id.clone(), + commit_id: input.commit_id.clone(), + idempotency_key: input.idempotency_key.clone(), + asset: asset.clone(), + manifest: manifest.clone(), + occurred_at, + }; + let mut ledger = AssetCanvasCommitLedger { + schema_version: ASSET_CANVAS_COMMIT_SCHEMA_VERSION.to_string(), + project_id: manifest.project_id.clone(), + draft_id: draft.draft_id.clone(), + commit_id: input.commit_id.clone(), + idempotency_key: input.idempotency_key.clone(), + request_fingerprint: request_fingerprint.clone(), + status: AssetCanvasLedgerStatus::Prepared, + expected_project_revision: input.expected_revision, + committed_project_revision: None, + expected_draft_revision: input.expected_draft_revision, + committed_draft_revision: None, + asset_id: None, + event_id: event_id.clone(), + event_payload: None, + event_delivery: AssetCanvasEventDelivery::Pending, + created_at: occurred_at, + updated_at: occurred_at, + }; + let mut journal = AssetCanvasTransactionJournal { + schema_version: ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION.to_string(), + project_id: manifest.project_id.clone(), + draft_id: draft.draft_id.clone(), + commit_id: input.commit_id.clone(), + idempotency_key: input.idempotency_key.clone(), + request_fingerprint, + stage: AssetCanvasTransactionStage::Prepared, + expected_project_revision: input.expected_revision, + target_project_revision, + expected_draft_revision: input.expected_draft_revision, + target_draft_revision, + staged_image: staged.clone(), + final_image_relative_path: final_relative_path.clone(), + final_image_sha256: staged.sha256.clone(), + final_image_existed_before: false, + manifest_before_sha256: asset_canvas_sha256(&manifest_before_bytes), + manifest_after_sha256: asset_canvas_sha256(&manifest_after_bytes), + project_revision_before_sha256: Some(asset_canvas_sha256(&revision_before_bytes)), + project_revision_after_sha256: asset_canvas_sha256(&revision_after_bytes), + asset_id: asset_id.clone(), + event_id: event_id.clone(), + occurred_at, + created_at: occurred_at, + updated_at: occurred_at, + }; + write_asset_canvas_transaction_snapshot( + root, + &input.commit_id, + "manifest.before.json", + &manifest_before_bytes, + )?; + write_asset_canvas_transaction_snapshot( + root, + &input.commit_id, + "manifest.after.json", + &manifest_after_bytes, + )?; + write_asset_canvas_transaction_snapshot( + root, + &input.commit_id, + "project-revision.before.json", + &revision_before_bytes, + )?; + write_asset_canvas_transaction_snapshot( + root, + &input.commit_id, + "project-revision.after.json", + &revision_after_bytes, + )?; + write_asset_canvas_journal(root, &journal)?; + write_asset_canvas_ledger(root, &ledger)?; + maybe_fail_asset_canvas_commit(fault, "prepared")?; + + install_new_asset_canvas_file(&final_path, &staged_bytes, "正式素材图片")?; + journal.stage = AssetCanvasTransactionStage::FileInstalled; + journal.updated_at = asset_canvas_now(); + write_asset_canvas_journal(root, &journal)?; + maybe_fail_asset_canvas_commit(fault, "file-installed")?; + + let manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?; + write_manifest(&manifest_path, &manifest)?; + journal.stage = AssetCanvasTransactionStage::ManifestInstalled; + journal.updated_at = asset_canvas_now(); + write_asset_canvas_journal(root, &journal)?; + maybe_fail_asset_canvas_commit(fault, "manifest-installed")?; + + write_game_creator_agent_runtime_project_revision(root, &target_revision_state)?; + journal.stage = AssetCanvasTransactionStage::RevisionInstalled; + journal.updated_at = asset_canvas_now(); + write_asset_canvas_journal(root, &journal)?; + maybe_fail_asset_canvas_commit(fault, "revision-installed")?; + + verify_committed_asset_canvas_state(root, &journal, &manifest, &target_revision_state)?; + journal.stage = AssetCanvasTransactionStage::Verified; + journal.updated_at = asset_canvas_now(); + write_asset_canvas_journal(root, &journal)?; + maybe_fail_asset_canvas_commit(fault, "verified")?; + + ledger.status = AssetCanvasLedgerStatus::Committed; + ledger.committed_project_revision = Some(target_project_revision); + ledger.committed_draft_revision = Some(target_draft_revision); + ledger.asset_id = Some(asset_id.clone()); + ledger.event_payload = Some(event_body.clone()); + ledger.updated_at = asset_canvas_now(); + write_asset_canvas_ledger(root, &ledger)?; + maybe_fail_asset_canvas_commit(fault, "ledger-committed")?; + + draft.revision = target_draft_revision; + draft.status = AssetCanvasDraftStatus::Committed; + draft.pending_commit = None; + draft.last_commit = Some(AssetCanvasLastCommit { + commit_id: input.commit_id.clone(), + idempotency_key: input.idempotency_key.clone(), + asset_id: asset_id.clone(), + event_id: event_id.clone(), + committed_project_revision: target_project_revision, + }); + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + journal.stage = AssetCanvasTransactionStage::Committed; + journal.updated_at = asset_canvas_now(); + write_asset_canvas_journal(root, &journal)?; + + Ok(CommitAssetCanvasExecution { + result: CommitAssetCanvasResult::Committed { + project_id: manifest.project_id.clone(), + project_revision: target_project_revision, + committed_project_revision: target_project_revision, + draft_id: draft.draft_id, + draft_revision: target_draft_revision, + commit_id: input.commit_id.clone(), + idempotency_key: input.idempotency_key.clone(), + event_id: event_id.clone(), + asset, + manifest, + }, + event: Some(event_body.with_project_path(root)), + }) +} + +pub(crate) fn commit_asset_canvas_at( + root: &Path, + input: &CommitAssetCanvasInput, +) -> Result { + commit_asset_canvas_at_internal(root, input, None) +} + +pub(crate) fn mark_asset_canvas_event_attempted_at( + root: &Path, + project_id: &str, + commit_id: &str, +) -> Result<(), String> { + validate_asset_canvas_project_identity(root, project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + validate_asset_canvas_project_identity(root, project_id)?; + let mut ledger = read_asset_canvas_ledger(root, commit_id)? + .ok_or_else(|| "素材画布 commit ledger 不存在".to_string())?; + if ledger.project_id != project_id || ledger.status != AssetCanvasLedgerStatus::Committed { + return Err("素材画布 commit ledger 尚未提交".to_string()); + } + ledger.event_delivery = AssetCanvasEventDelivery::Attempted; + ledger.updated_at = asset_canvas_now(); + write_asset_canvas_ledger(root, &ledger)?; + if let Some(mut journal) = read_asset_canvas_journal(root, commit_id)? { + if journal.stage == AssetCanvasTransactionStage::Committed { + journal.stage = AssetCanvasTransactionStage::EventAttempted; + journal.updated_at = asset_canvas_now(); + write_asset_canvas_journal(root, &journal)?; + } + } + Ok(()) +} + +pub(crate) fn publish_asset_canvas_event_after_commit_at( + root: &Path, + event: &AssetCanvasCommittedEvent, + emit: F, +) -> bool +where + F: FnOnce(&AssetCanvasCommittedEvent) -> Result<(), String>, +{ + let emitted = emit(event).is_ok(); + let _ = mark_asset_canvas_event_attempted_at(root, &event.project_id, &event.commit_id); + emitted +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct RecoverAssetCanvasTransactionsInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum RecoverAssetCanvasOutcomeStatus { + Committed, + AlreadyCommitted, + RolledBack, + ReconciliationRequired, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RecoverAssetCanvasOutcome { + pub(crate) commit_id: String, + pub(crate) status: RecoverAssetCanvasOutcomeStatus, + pub(crate) event_id: Option, + pub(crate) asset_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RecoverAssetCanvasTransactionsResult { + pub(crate) project_id: String, + pub(crate) project_revision: u64, + pub(crate) manifest: GameCreationAppManifest, + pub(crate) outcomes: Vec, +} + +#[derive(Debug)] +pub(crate) struct RecoverAssetCanvasExecution { + pub(crate) result: RecoverAssetCanvasTransactionsResult, + pub(crate) events: Vec, +} + +fn read_asset_canvas_snapshot( + root: &Path, + commit_id: &str, + file_name: &str, + expected_sha256: &str, +) -> Result { + let path = resolve_local_project_path( + root, + &asset_canvas_transaction_relative_path(commit_id, file_name), + )?; + let (mut file, metadata) = open_project_snapshot_regular_file(&path, "素材画布事务快照")?; + if metadata.len() > ASSET_CANVAS_MAX_LEDGER_BYTES as u64 { + return Err("素材画布事务快照超限".to_string()); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take((ASSET_CANVAS_MAX_LEDGER_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| "读取素材画布事务快照失败".to_string())?; + if asset_canvas_sha256(&bytes) != expected_sha256 { + return Err("素材画布事务快照摘要不匹配".to_string()); + } + serde_json::from_slice(&bytes).map_err(|_| "解析素材画布事务快照失败".to_string()) +} + +fn current_manifest_sha(root: &Path) -> Result<(GameCreationAppManifest, String), String> { + let manifest = current_asset_canvas_manifest(root)?; + let sha = asset_canvas_sha256(&asset_canvas_json_bytes(&manifest)?); + Ok((manifest, sha)) +} + +fn current_revision_sha(root: &Path) -> Result<(AgentRuntimeProjectRevision, String), String> { + let revision = read_game_creator_agent_runtime_project_revision(root)?; + let sha = asset_canvas_sha256(&asset_canvas_json_bytes(&revision)?); + Ok((revision, sha)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AssetCanvasFinalImageState { + Absent, + Matches, + Mismatch, +} + +fn final_image_state_for_journal( + root: &Path, + journal: &AssetCanvasTransactionJournal, +) -> Result { + let path = resolve_local_project_path(root, &journal.final_image_relative_path)?; + match fs::symlink_metadata(&path) { + Ok(_) => Ok( + if open_and_validate_image_file( + &path, + &journal.staged_image.media_type, + Some(&journal.final_image_sha256), + ) + .is_ok_and(|(bytes, width, height)| { + bytes.len() as u64 == journal.staged_image.byte_length + && width == journal.staged_image.pixel_width + && height == journal.staged_image.pixel_height + }) { + AssetCanvasFinalImageState::Matches + } else { + AssetCanvasFinalImageState::Mismatch + }, + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(AssetCanvasFinalImageState::Absent) + } + Err(_) => Err("读取正式素材恢复目标失败".to_string()), + } +} + +fn mark_asset_canvas_reconciliation_locked( + root: &Path, + mut journal: AssetCanvasTransactionJournal, + mut ledger: AssetCanvasCommitLedger, +) -> Result { + journal.stage = AssetCanvasTransactionStage::ReconciliationRequired; + journal.updated_at = asset_canvas_now(); + ledger.status = AssetCanvasLedgerStatus::ReconciliationRequired; + ledger.updated_at = asset_canvas_now(); + write_asset_canvas_journal(root, &journal)?; + write_asset_canvas_ledger(root, &ledger)?; + if let Some(mut draft) = + read_asset_canvas_draft_locked(root, &journal.project_id, &journal.draft_id)? + { + if draft.revision == journal.expected_draft_revision { + draft.revision = journal.target_draft_revision; + draft.status = AssetCanvasDraftStatus::ReconciliationRequired; + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + } + } + Ok(RecoverAssetCanvasOutcome { + commit_id: journal.commit_id, + status: RecoverAssetCanvasOutcomeStatus::ReconciliationRequired, + event_id: Some(journal.event_id), + asset_id: None, + }) +} + +fn finish_recovered_asset_canvas_commit_locked( + root: &Path, + mut journal: AssetCanvasTransactionJournal, + mut ledger: AssetCanvasCommitLedger, + manifest_after: GameCreationAppManifest, + revision_after: AgentRuntimeProjectRevision, +) -> Result<(RecoverAssetCanvasOutcome, AssetCanvasCommittedEvent), String> { + let asset = + verify_committed_asset_canvas_state(root, &journal, &manifest_after, &revision_after)?; + let event_body = + ledger + .event_payload + .clone() + .unwrap_or_else(|| AssetCanvasCommittedEventBody { + schema_version: ASSET_CANVAS_EVENT_SCHEMA_VERSION.to_string(), + event_id: journal.event_id.clone(), + project_id: journal.project_id.clone(), + committed_project_revision: journal.target_project_revision, + draft_id: journal.draft_id.clone(), + commit_id: journal.commit_id.clone(), + idempotency_key: journal.idempotency_key.clone(), + asset: asset.clone(), + manifest: manifest_after.clone(), + occurred_at: journal.occurred_at, + }); + ledger.status = AssetCanvasLedgerStatus::Committed; + ledger.committed_project_revision = Some(journal.target_project_revision); + ledger.committed_draft_revision = Some(journal.target_draft_revision); + ledger.asset_id = Some(journal.asset_id.clone()); + ledger.event_payload = Some(event_body.clone()); + ledger.updated_at = asset_canvas_now(); + write_asset_canvas_ledger(root, &ledger)?; + let mut draft = read_asset_canvas_draft_locked(root, &journal.project_id, &journal.draft_id)? + .ok_or_else(|| "恢复提交时草稿不存在".to_string())?; + let already_matches = draft.last_commit.as_ref().is_some_and(|last| { + last.commit_id == journal.commit_id + && last.idempotency_key == journal.idempotency_key + && last.asset_id == journal.asset_id + && last.event_id == journal.event_id + && last.committed_project_revision == journal.target_project_revision + }); + if !already_matches { + if draft.revision != journal.expected_draft_revision { + return Err("恢复提交时草稿 revision 已被其它写入推进".to_string()); + } + draft.revision = journal.target_draft_revision; + draft.status = AssetCanvasDraftStatus::Committed; + draft.pending_commit = None; + draft.last_commit = Some(AssetCanvasLastCommit { + commit_id: journal.commit_id.clone(), + idempotency_key: journal.idempotency_key.clone(), + asset_id: journal.asset_id.clone(), + event_id: journal.event_id.clone(), + committed_project_revision: journal.target_project_revision, + }); + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + } + journal.stage = AssetCanvasTransactionStage::Committed; + journal.updated_at = asset_canvas_now(); + write_asset_canvas_journal(root, &journal)?; + Ok(( + RecoverAssetCanvasOutcome { + commit_id: journal.commit_id.clone(), + status: RecoverAssetCanvasOutcomeStatus::Committed, + event_id: Some(journal.event_id.clone()), + asset_id: Some(journal.asset_id.clone()), + }, + event_body.with_project_path(root), + )) +} + +fn recover_asset_canvas_transaction_locked( + root: &Path, + journal: AssetCanvasTransactionJournal, +) -> Result<(RecoverAssetCanvasOutcome, Option), String> { + if journal.schema_version != ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION { + return Err("不支持的素材画布 transaction schema".to_string()); + } + let mut ledger = read_asset_canvas_ledger(root, &journal.commit_id)? + .ok_or_else(|| "素材画布 transaction 缺少 commit ledger".to_string())?; + if ledger.schema_version != ASSET_CANVAS_COMMIT_SCHEMA_VERSION + || ledger.project_id != journal.project_id + || ledger.draft_id != journal.draft_id + || ledger.idempotency_key != journal.idempotency_key + || ledger.request_fingerprint != journal.request_fingerprint + { + return Err("素材画布 journal 与 ledger 身份不一致".to_string()); + } + let manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot( + root, + &journal.commit_id, + "manifest.after.json", + &journal.manifest_after_sha256, + )?; + let revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot( + root, + &journal.commit_id, + "project-revision.after.json", + &journal.project_revision_after_sha256, + )?; + let (current_manifest, current_manifest_sha) = current_manifest_sha(root)?; + let (current_revision, current_revision_sha) = current_revision_sha(root)?; + let final_image_state = final_image_state_for_journal(root, &journal)?; + let file_matches = final_image_state == AssetCanvasFinalImageState::Matches; + + if ledger.status == AssetCanvasLedgerStatus::Committed { + let asset_present = current_manifest.assets.iter().any(|asset| { + asset.id == journal.asset_id && asset.local_path == journal.final_image_relative_path + }); + if file_matches + && asset_present + && current_revision.revision >= journal.target_project_revision + { + let mut draft = + read_asset_canvas_draft_locked(root, &journal.project_id, &journal.draft_id)? + .ok_or_else(|| "已提交事务缺少草稿".to_string())?; + if !draft + .last_commit + .as_ref() + .is_some_and(|last| last.commit_id == journal.commit_id) + { + if draft.revision != journal.expected_draft_revision { + return mark_asset_canvas_reconciliation_locked(root, journal, ledger) + .map(|outcome| (outcome, None)); + } + draft.revision = journal.target_draft_revision; + draft.status = AssetCanvasDraftStatus::Committed; + draft.last_commit = Some(AssetCanvasLastCommit { + commit_id: journal.commit_id.clone(), + idempotency_key: journal.idempotency_key.clone(), + asset_id: journal.asset_id.clone(), + event_id: journal.event_id.clone(), + committed_project_revision: journal.target_project_revision, + }); + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + } + let event = ledger + .event_payload + .as_ref() + .map(|payload| payload.with_project_path(root)); + return Ok(( + RecoverAssetCanvasOutcome { + commit_id: journal.commit_id, + status: RecoverAssetCanvasOutcomeStatus::AlreadyCommitted, + event_id: Some(journal.event_id), + asset_id: Some(journal.asset_id), + }, + event, + )); + } + return mark_asset_canvas_reconciliation_locked(root, journal, ledger) + .map(|outcome| (outcome, None)); + } + + let manifest_before = current_manifest_sha == journal.manifest_before_sha256; + let manifest_is_after = current_manifest_sha == journal.manifest_after_sha256; + let revision_before = + journal.project_revision_before_sha256.as_deref() == Some(current_revision_sha.as_str()); + let revision_is_after = current_revision_sha == journal.project_revision_after_sha256; + if manifest_before && revision_before { + match final_image_state { + AssetCanvasFinalImageState::Matches => { + let path = resolve_local_project_path(root, &journal.final_image_relative_path)?; + fs::remove_file(&path).map_err(|_| "安全回滚当前事务新文件失败".to_string())?; + } + AssetCanvasFinalImageState::Absent => {} + AssetCanvasFinalImageState::Mismatch => { + return mark_asset_canvas_reconciliation_locked(root, journal, ledger) + .map(|outcome| (outcome, None)); + } + } + ledger.status = AssetCanvasLedgerStatus::RolledBack; + ledger.updated_at = asset_canvas_now(); + write_asset_canvas_ledger(root, &ledger)?; + let mut rolled_back = journal.clone(); + rolled_back.stage = AssetCanvasTransactionStage::RolledBack; + rolled_back.updated_at = asset_canvas_now(); + write_asset_canvas_journal(root, &rolled_back)?; + return Ok(( + RecoverAssetCanvasOutcome { + commit_id: journal.commit_id, + status: RecoverAssetCanvasOutcomeStatus::RolledBack, + event_id: Some(journal.event_id), + asset_id: None, + }, + None, + )); + } + if manifest_is_after && revision_before && file_matches { + write_game_creator_agent_runtime_project_revision(root, &revision_after)?; + return finish_recovered_asset_canvas_commit_locked( + root, + journal, + ledger, + manifest_after, + revision_after, + ) + .map(|(outcome, event)| (outcome, Some(event))); + } + if manifest_is_after && revision_is_after && file_matches { + return finish_recovered_asset_canvas_commit_locked( + root, + journal, + ledger, + manifest_after, + revision_after, + ) + .map(|(outcome, event)| (outcome, Some(event))); + } + mark_asset_canvas_reconciliation_locked(root, journal, ledger).map(|outcome| (outcome, None)) +} + +pub(crate) fn recover_asset_canvas_transactions_at( + root: &Path, + expected_project_id: &str, +) -> Result { + validate_asset_canvas_project_identity(root, expected_project_id)?; + let _project_lock = acquire_project_write_lock(root, "asset-canvas.recover")?; + let _draft_lock = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?; + let transactions = + resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/transactions"))?; + let mut commit_ids = Vec::new(); + match fs::read_dir(&transactions) { + Ok(entries) => { + for entry in entries { + let entry = entry.map_err(|_| "读取素材画布 transaction 目录失败".to_string())?; + let metadata = fs::symlink_metadata(entry.path()) + .map_err(|_| "读取素材画布 transaction 元数据失败".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("素材画布 transaction 必须是普通目录".to_string()); + } + let id = entry.file_name().to_string_lossy().into_owned(); + validate_uuid_v4(&id, "transaction commitId")?; + commit_ids.push(id); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("读取素材画布 transaction 目录失败".to_string()), + } + commit_ids.sort(); + if commit_ids.len() > 4096 { + return Err("素材画布 transaction 数量超限".to_string()); + } + let mut outcomes = Vec::new(); + let mut events = Vec::new(); + for commit_id in commit_ids { + let Some(journal) = read_asset_canvas_journal(root, &commit_id)? else { + return Err("素材画布 transaction 缺少 journal".to_string()); + }; + let (outcome, event) = recover_asset_canvas_transaction_locked(root, journal)?; + outcomes.push(outcome); + if let Some(event) = event { + events.push(event); + } + } + let final_manifest = current_asset_canvas_manifest(root)?; + let revision = read_game_creator_agent_runtime_project_revision(root)?; + if final_manifest.project_id != manifest.project_id { + return Err("项目身份在 transaction 恢复期间发生变化".to_string()); + } + Ok(RecoverAssetCanvasExecution { + result: RecoverAssetCanvasTransactionsResult { + project_id: final_manifest.project_id.clone(), + project_revision: revision.revision, + manifest: final_manifest, + outcomes, + }, + events, + }) +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct DiscardAssetCanvasDraftInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) draft_id: String, + pub(crate) expected_draft_revision: u64, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DiscardAssetCanvasDraftResult { + pub(crate) status: String, + pub(crate) draft: AssetCanvasDraft, +} + +pub(crate) fn discard_asset_canvas_draft_at( + root: &Path, + input: &DiscardAssetCanvasDraftInput, +) -> Result { + validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.revision != input.expected_draft_revision { + return Ok(DiscardAssetCanvasDraftResult { + status: "conflict".to_string(), + draft, + }); + } + if matches!( + draft.status, + AssetCanvasDraftStatus::CommitPrepared | AssetCanvasDraftStatus::ReconciliationRequired + ) { + return Ok(DiscardAssetCanvasDraftResult { + status: "commit-in-progress".to_string(), + draft, + }); + } + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; + draft.status = AssetCanvasDraftStatus::Cancelled; + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + Ok(DiscardAssetCanvasDraftResult { + status: "cancelled".to_string(), + draft, + }) +} + +#[cfg(test)] +#[path = "asset_canvas_tests.rs"] +mod tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs new file mode 100644 index 000000000..66975d609 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs @@ -0,0 +1,865 @@ +use super::*; +use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; +use std::io::Cursor; +use std::sync::{Arc, Barrier}; + +const PROJECT_ID: &str = "asset-canvas-test-project"; + +struct Fixture { + directory: tempfile::TempDir, + draft: AssetCanvasDraft, + png: Vec, +} + +impl Fixture { + fn root(&self) -> &Path { + self.directory.path() + } +} + +fn png_bytes(color: [u8; 4]) -> Vec { + let image = RgbaImage::from_pixel(4, 3, Rgba(color)); + let mut output = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image) + .write_to(&mut output, ImageFormat::Png) + .expect("encode PNG fixture"); + output.into_inner() +} + +fn project_path(root: &Path) -> String { + root.to_string_lossy().into_owned() +} + +fn create_input( + root: &Path, + draft_id: &str, + intent: AssetCanvasIntent, + source_asset_id: Option, +) -> CreateAssetCanvasDraftInput { + CreateAssetCanvasDraftInput { + project_path: project_path(root), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft_id.to_string(), + intent, + source_asset_id, + } +} + +fn initialize_fixture() -> Fixture { + let directory = tempfile::tempdir().expect("create asset canvas fixture"); + init_local_game_project_at(directory.path(), PROJECT_ID, "素材画布测试项目") + .expect("initialize project"); + let draft_id = Uuid::new_v4().to_string(); + let draft = create_asset_canvas_draft_at( + directory.path(), + &create_input(directory.path(), &draft_id, AssetCanvasIntent::Create, None), + ) + .expect("create draft") + .draft; + Fixture { + directory, + draft, + png: png_bytes([33, 99, 180, 255]), + } +} + +fn add_imported_layer(fixture: &Fixture, draft: &AssetCanvasDraft) -> AssetCanvasDraft { + let stored = store_asset_canvas_media_at( + fixture.root(), + &StoreAssetCanvasMediaInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + media_type: "image/png".to_string(), + bytes: fixture.png.clone(), + }, + ) + .expect("store imported media"); + let media_ref = stored.media_ref.expect("stored media ref"); + let mut canvas = draft.canvas.clone(); + let layer_id = Uuid::new_v4().to_string(); + canvas.layers.push(AssetCanvasLayer { + layer_id: layer_id.clone(), + resource_id: format!("draft-media:{layer_id}"), + title: "导入图片".to_string(), + media_ref, + x: 20.0, + y: 30.0, + width: 400.0, + height: 300.0, + original_width: 4.0, + original_height: 3.0, + z_index: 0, + group_id: None, + hidden: false, + locked: false, + flip_x: false, + flip_y: false, + }); + canvas.selected_layer_ids = vec![layer_id.clone()]; + canvas.primary_selected_layer_id = Some(layer_id); + update_asset_canvas_draft_at( + fixture.root(), + &UpdateAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + status: AssetCanvasDraftStatus::Editing, + canvas, + generations: Vec::new(), + }, + ) + .expect("persist imported layer") + .draft +} + +fn stage_image(fixture: &Fixture, draft: &AssetCanvasDraft) -> StageAssetCanvasImageResult { + stage_asset_canvas_image_at( + fixture.root(), + &StageAssetCanvasImageInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + media_type: "image/png".to_string(), + bytes: fixture.png.clone(), + }, + ) + .expect("stage image") +} + +fn commit_input( + fixture: &Fixture, + draft: &AssetCanvasDraft, + staged: &StageAssetCanvasImageResult, + commit_id: String, + idempotency_key: String, +) -> CommitAssetCanvasInput { + CommitAssetCanvasInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + expected_revision: read_game_creator_agent_runtime_project_revision(fixture.root()) + .expect("read revision") + .revision, + expected_draft_revision: draft.revision, + draft_id: draft.draft_id.clone(), + commit_id, + idempotency_key, + intent: draft.intent.clone(), + source_asset_id: draft.source_asset_id.clone(), + staged_image_token: staged.staged_image_token.clone().expect("staging token"), + name: "测试素材".to_string(), + asset_kind: "illustration".to_string(), + reference_resource_ids: Vec::new(), + generation_provenance: None, + } +} + +#[test] +fn create_import_edit_commit_installs_file_and_manifest_and_replays_idempotently() { + let fixture = initialize_fixture(); + let draft = add_imported_layer(&fixture, &fixture.draft); + let staged = stage_image(&fixture, &draft); + let mut input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + let first = commit_asset_canvas_at(fixture.root(), &input).expect("commit asset"); + let (asset, event_id) = match first.result { + CommitAssetCanvasResult::Committed { + asset, + event_id, + project_revision, + draft_revision, + .. + } => { + assert_eq!(project_revision, 1); + assert_eq!(draft_revision, draft.revision + 1); + (asset, event_id) + } + other => panic!("unexpected commit result: {other:?}"), + }; + assert_eq!( + fs::read(fixture.root().join(&asset.local_path)).expect("read committed image"), + fixture.png + ); + let manifest = current_asset_canvas_manifest(fixture.root()).expect("read committed manifest"); + assert!(manifest.assets.iter().any(|entry| entry == &asset)); + let replay = commit_asset_canvas_at(fixture.root(), &input).expect("replay commit"); + match replay.result { + CommitAssetCanvasResult::AlreadyCommitted { + event_id: replay_event_id, + asset: replay_asset, + .. + } => { + assert_eq!(replay_event_id, event_id); + assert_eq!(replay_asset, asset); + } + other => panic!("unexpected replay result: {other:?}"), + } + input.name = "不同素材名".to_string(); + assert!(commit_asset_canvas_at(fixture.root(), &input) + .expect_err("reject idempotency identity reuse") + .contains("已绑定到不同提交请求")); +} + +#[test] +fn event_emit_failure_does_not_rollback_committed_business_state() { + let fixture = initialize_fixture(); + let draft = add_imported_layer(&fixture, &fixture.draft); + let staged = stage_image(&fixture, &draft); + let input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + let execution = commit_asset_canvas_at(fixture.root(), &input).expect("commit before emit"); + let event = execution.event.as_ref().expect("committed event"); + let manifest_before_emit = current_asset_canvas_manifest(fixture.root()).expect("manifest"); + let revision_before_emit = read_game_creator_agent_runtime_project_revision(fixture.root()) + .expect("revision") + .revision; + assert!(!publish_asset_canvas_event_after_commit_at( + fixture.root(), + event, + |_| Err("injected emit failure".to_string()), + )); + assert_eq!( + current_asset_canvas_manifest(fixture.root()).expect("manifest after failed emit"), + manifest_before_emit + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(fixture.root()) + .expect("revision after failed emit") + .revision, + revision_before_emit + ); + let ledger = read_asset_canvas_ledger(fixture.root(), &input.commit_id) + .expect("read ledger") + .expect("committed ledger"); + assert_eq!(ledger.status, AssetCanvasLedgerStatus::Committed); + assert_eq!(ledger.event_delivery, AssetCanvasEventDelivery::Attempted); +} + +#[test] +fn refine_preserves_source_and_records_non_destructive_lineage() { + let directory = tempfile::tempdir().expect("create refine fixture"); + init_local_game_project_at(directory.path(), PROJECT_ID, "精修测试项目") + .expect("initialize project"); + let source_bytes = png_bytes([200, 20, 40, 255]); + fs::write(directory.path().join("assets/source.png"), &source_bytes) + .expect("write source asset"); + let manifest_path = directory.path().join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path).expect("read manifest"); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: "source-asset".to_string(), + kind: "illustration".to_string(), + media_type: "image/png".to_string(), + local_path: "assets/source.png".to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + 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(), + }, + }); + write_manifest(&manifest_path, &manifest).expect("register source asset"); + let draft = create_asset_canvas_draft_at( + directory.path(), + &create_input( + directory.path(), + &Uuid::new_v4().to_string(), + AssetCanvasIntent::Refine, + Some("source-asset".to_string()), + ), + ) + .expect("create refine draft") + .draft; + let fixture = Fixture { + directory, + draft: draft.clone(), + png: png_bytes([20, 200, 40, 255]), + }; + let staged = stage_image(&fixture, &draft); + let input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + let execution = commit_asset_canvas_at(fixture.root(), &input).expect("commit refine asset"); + let new_asset = match execution.result { + CommitAssetCanvasResult::Committed { asset, .. } => asset, + other => panic!("unexpected refine result: {other:?}"), + }; + assert_eq!( + fs::read(fixture.root().join("assets/source.png")).expect("read source"), + source_bytes + ); + let manifest = current_asset_canvas_manifest(fixture.root()).expect("read refine manifest"); + let source = manifest + .assets + .iter() + .find(|asset| asset.id == "source-asset") + .expect("source retained"); + assert_eq!( + source.source.resource_id.as_deref(), + Some("local-asset:source-asset") + ); + assert_ne!(new_asset.id, source.id); + assert_ne!(new_asset.local_path, source.local_path); + assert!(new_asset + .source + .reference_resource_ids + .contains(&"local-asset:source-asset".to_string())); +} + +#[test] +fn draft_cas_continuous_saves_and_trusted_restart_recovery() { + let fixture = initialize_fixture(); + let mut draft = fixture.draft.clone(); + for offset in [10.0, 20.0, 30.0] { + let mut canvas = draft.canvas.clone(); + canvas.viewport.x = offset; + let result = update_asset_canvas_draft_at( + fixture.root(), + &UpdateAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + status: AssetCanvasDraftStatus::Editing, + canvas, + generations: Vec::new(), + }, + ) + .expect("continuous draft save"); + assert_eq!(result.status, UpdateAssetCanvasDraftStatus::Updated); + draft = result.draft; + } + assert_eq!(draft.revision, 3); + fs::write( + fixture + .root() + .join(asset_canvas_draft_relative_path(&draft.draft_id)), + b"{broken", + ) + .expect("corrupt primary draft"); + let recovered = read_asset_canvas_draft_at( + fixture.root(), + &ReadAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft.draft_id.clone(), + }, + ) + .expect("recover trusted draft") + .draft + .expect("recovered draft"); + assert_eq!(recovered.revision, 2); + assert_eq!(recovered.canvas.viewport.x, 20.0); + + let mut canvas = recovered.canvas.clone(); + canvas.viewport.x = 40.0; + update_asset_canvas_draft_at( + fixture.root(), + &UpdateAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: recovered.draft_id.clone(), + expected_draft_revision: recovered.revision, + status: AssetCanvasDraftStatus::Editing, + canvas, + generations: Vec::new(), + }, + ) + .expect("write another draft revision"); + fs::write( + fixture + .root() + .join(asset_canvas_recovery_sha_relative_path(&recovered.draft_id)), + format!("{}\n", "0".repeat(64)), + ) + .expect("corrupt recovery digest"); + fs::write( + fixture + .root() + .join(asset_canvas_draft_relative_path(&recovered.draft_id)), + b"{broken-again", + ) + .expect("corrupt primary draft again"); + assert!(read_asset_canvas_draft_at( + fixture.root(), + &ReadAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: recovered.draft_id, + }, + ) + .expect_err("reject untrusted recovery digest") + .contains("reconciliation-required")); +} + +#[test] +fn draft_media_total_limit_rejects_additional_import() { + let fixture = initialize_fixture(); + let media_directory = fixture.root().join(format!( + "{ASSET_CANVAS_ROOT}/media/{}", + fixture.draft.draft_id + )); + fs::create_dir_all(&media_directory).expect("create draft media directory"); + let sparse = fs::File::create(media_directory.join("existing.bin")) + .expect("create sparse media fixture"); + sparse + .set_len(ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES) + .expect("size sparse media fixture"); + assert!(store_asset_canvas_media_at( + fixture.root(), + &StoreAssetCanvasMediaInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + media_type: "image/png".to_string(), + bytes: fixture.png.clone(), + }, + ) + .expect_err("reject draft media total overflow") + .contains("512 MiB")); +} + +#[test] +fn same_draft_revision_concurrent_cas_has_exactly_one_winner() { + let fixture = initialize_fixture(); + let root = fixture.root().to_path_buf(); + let draft = fixture.draft.clone(); + let barrier = Arc::new(Barrier::new(3)); + let mut threads = Vec::new(); + for x in [101.0, 202.0] { + let root = root.clone(); + let draft = draft.clone(); + let barrier = Arc::clone(&barrier); + threads.push(std::thread::spawn(move || { + let mut canvas = draft.canvas.clone(); + canvas.viewport.x = x; + barrier.wait(); + update_asset_canvas_draft_at( + &root, + &UpdateAssetCanvasDraftInput { + project_path: project_path(&root), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft.draft_id, + expected_draft_revision: 0, + status: AssetCanvasDraftStatus::Editing, + canvas, + generations: Vec::new(), + }, + ) + .expect("concurrent CAS") + .status + })); + } + barrier.wait(); + let statuses = threads + .into_iter() + .map(|thread| thread.join().expect("join CAS writer")) + .collect::>(); + assert_eq!( + statuses + .iter() + .filter(|status| **status == UpdateAssetCanvasDraftStatus::Updated) + .count(), + 1 + ); + assert_eq!( + statuses + .iter() + .filter(|status| **status == UpdateAssetCanvasDraftStatus::Conflict) + .count(), + 1 + ); +} + +#[test] +fn same_project_revision_double_commit_cannot_overwrite() { + let fixture = initialize_fixture(); + let draft = add_imported_layer(&fixture, &fixture.draft); + let staged_one = stage_image(&fixture, &draft); + let staged_two = stage_image(&fixture, &draft); + let first = commit_input( + &fixture, + &draft, + &staged_one, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + let mut second = commit_input( + &fixture, + &draft, + &staged_two, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + second.expected_revision = first.expected_revision; + assert!(matches!( + commit_asset_canvas_at(fixture.root(), &first) + .expect("first writer") + .result, + CommitAssetCanvasResult::Committed { .. } + )); + assert!(matches!( + commit_asset_canvas_at(fixture.root(), &second) + .expect("second writer") + .result, + CommitAssetCanvasResult::Conflict { + conflict_kind: AssetCanvasConflictKind::ProjectRevision, + .. + } + )); + assert_eq!( + current_asset_canvas_manifest(fixture.root()) + .expect("manifest") + .assets + .iter() + .filter(|asset| asset.id.starts_with("canvas-")) + .count(), + 1 + ); +} + +#[test] +fn every_commit_fault_stage_recovers_without_ambiguous_overwrite() { + for fault in [ + AssetCanvasCommitFaultStage::Prepared, + AssetCanvasCommitFaultStage::FileInstalled, + AssetCanvasCommitFaultStage::ManifestInstalled, + AssetCanvasCommitFaultStage::RevisionInstalled, + AssetCanvasCommitFaultStage::Verified, + AssetCanvasCommitFaultStage::LedgerCommitted, + ] { + let fixture = initialize_fixture(); + let draft = add_imported_layer(&fixture, &fixture.draft); + let staged = stage_image(&fixture, &draft); + let input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + let error = commit_asset_canvas_at_internal(fixture.root(), &input, Some(fault)) + .expect_err("inject commit fault"); + assert!(error.starts_with("fault-injected:")); + let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("recover injected fault"); + let outcome = recovered + .result + .outcomes + .iter() + .find(|outcome| outcome.commit_id == input.commit_id) + .expect("recovery outcome"); + match fault { + AssetCanvasCommitFaultStage::Prepared | AssetCanvasCommitFaultStage::FileInstalled => { + assert_eq!(outcome.status, RecoverAssetCanvasOutcomeStatus::RolledBack); + assert!(!fixture + .root() + .join(format!("assets/canvas/测试素材--{}.png", input.commit_id)) + .exists()); + let recovered_draft = read_asset_canvas_draft_at( + fixture.root(), + &ReadAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft.draft_id.clone(), + }, + ) + .expect("read rolled back draft") + .draft + .expect("rolled back draft retained"); + assert_eq!(recovered_draft.status, AssetCanvasDraftStatus::Editing); + assert_eq!(recovered_draft.revision, draft.revision); + assert!(recovered_draft.pending_commit.is_none()); + } + _ => { + assert!(matches!( + outcome.status, + RecoverAssetCanvasOutcomeStatus::Committed + | RecoverAssetCanvasOutcomeStatus::AlreadyCommitted + )); + assert_eq!( + read_game_creator_agent_runtime_project_revision(fixture.root()) + .expect("revision after recovery") + .revision, + 1 + ); + let recovered_draft = read_asset_canvas_draft_at( + fixture.root(), + &ReadAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft.draft_id.clone(), + }, + ) + .expect("read committed draft") + .draft + .expect("committed draft retained"); + assert_eq!(recovered_draft.status, AssetCanvasDraftStatus::Committed); + assert_eq!(recovered_draft.revision, draft.revision + 1); + assert!(recovered_draft.pending_commit.is_none()); + assert_eq!( + recovered_draft + .last_commit + .as_ref() + .map(|last| last.commit_id.as_str()), + Some(input.commit_id.as_str()) + ); + } + } + } +} + +#[test] +fn mismatched_installed_file_requires_reconciliation_and_is_not_deleted() { + let fixture = initialize_fixture(); + let draft = add_imported_layer(&fixture, &fixture.draft); + let staged = stage_image(&fixture, &draft); + let input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + commit_asset_canvas_at_internal( + fixture.root(), + &input, + Some(AssetCanvasCommitFaultStage::FileInstalled), + ) + .expect_err("stop after final file installation"); + let final_path = fixture + .root() + .join(format!("assets/canvas/测试素材--{}.png", input.commit_id)); + let replacement = png_bytes([220, 70, 10, 255]); + fs::write(&final_path, &replacement).expect("replace installed bytes"); + let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("recover mismatched final file"); + let outcome = recovered + .result + .outcomes + .iter() + .find(|outcome| outcome.commit_id == input.commit_id) + .expect("mismatched recovery outcome"); + assert_eq!( + outcome.status, + RecoverAssetCanvasOutcomeStatus::ReconciliationRequired + ); + assert_eq!( + fs::read(final_path).expect("retain unknown file"), + replacement + ); +} + +#[cfg(unix)] +#[test] +fn rejects_symlinked_or_hardlinked_media_at_source_staging_and_final_paths() { + use std::os::unix::fs::symlink; + + let source_directory = tempfile::tempdir().expect("create linked source fixture"); + init_local_game_project_at(source_directory.path(), PROJECT_ID, "链接源资源测试") + .expect("initialize linked source project"); + let real_source = source_directory.path().join("assets/real.png"); + fs::write(&real_source, png_bytes([1, 2, 3, 255])).expect("write real source"); + let source_path = source_directory.path().join("assets/source.png"); + symlink("real.png", &source_path).expect("symlink source asset"); + let manifest_path = source_directory.path().join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path).expect("read linked source manifest"); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: "linked-source".to_string(), + kind: "illustration".to_string(), + media_type: "image/png".to_string(), + local_path: "assets/source.png".to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + 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(), + }, + }); + write_manifest(&manifest_path, &manifest).expect("register linked source"); + assert!(create_asset_canvas_draft_at( + source_directory.path(), + &create_input( + source_directory.path(), + &Uuid::new_v4().to_string(), + AssetCanvasIntent::Refine, + Some("linked-source".to_string()), + ), + ) + .is_err()); + fs::remove_file(&source_path).expect("remove source symlink"); + fs::hard_link(&real_source, &source_path).expect("hardlink source asset"); + assert!(create_asset_canvas_draft_at( + source_directory.path(), + &create_input( + source_directory.path(), + &Uuid::new_v4().to_string(), + AssetCanvasIntent::Refine, + Some("linked-source".to_string()), + ), + ) + .expect_err("reject hardlinked source asset") + .contains("硬链接")); + + let fixture = initialize_fixture(); + let draft = add_imported_layer(&fixture, &fixture.draft); + let staged = stage_image(&fixture, &draft); + let staging_image = fixture.root().join(format!( + "{ASSET_CANVAS_ROOT}/staging/{}/image.png", + staged.staged_image_token.as_deref().expect("staging token") + )); + fs::hard_link(&staging_image, fixture.root().join("staging-hardlink.png")) + .expect("hardlink staging image"); + let staging_input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + assert!(commit_asset_canvas_at(fixture.root(), &staging_input) + .expect_err("reject hardlinked staging image") + .contains("硬链接")); + + fs::remove_file(fixture.root().join("staging-hardlink.png")).expect("remove staging hardlink"); + let final_input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + let final_path = fixture.root().join(format!( + "assets/canvas/测试素材--{}.png", + final_input.commit_id + )); + fs::create_dir_all(final_path.parent().expect("final parent")).expect("create final parent"); + symlink("../occupied.png", &final_path).expect("symlink final target"); + assert!(commit_asset_canvas_at(fixture.root(), &final_input).is_err()); + assert!(fs::symlink_metadata(final_path) + .expect("final symlink retained") + .file_type() + .is_symlink()); +} + +#[test] +fn rejects_bad_signature_oversize_identity_replacement_and_linked_sidecars() { + let fixture = initialize_fixture(); + let bad_signature = store_asset_canvas_media_at( + fixture.root(), + &StoreAssetCanvasMediaInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: 0, + media_type: "image/png".to_string(), + bytes: b"not a png".to_vec(), + }, + ) + .expect_err("reject bad signature"); + assert!(bad_signature.contains("签名")); + assert!(store_asset_canvas_media_at( + fixture.root(), + &StoreAssetCanvasMediaInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: 0, + media_type: "image/png".to_string(), + bytes: vec![0_u8; ASSET_CANVAS_MAX_MEDIA_BYTES + 1], + }, + ) + .expect_err("reject oversized image") + .contains("大小")); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let manifest_path = fixture.root().join(".agent/manifest.json"); + let manifest = read_manifest(&manifest_path).expect("read manifest"); + let draft_path = fixture + .root() + .join(asset_canvas_draft_relative_path(&fixture.draft.draft_id)); + let hardlink = fixture.root().join("draft-hardlink.json"); + fs::hard_link(&draft_path, &hardlink).expect("hardlink draft"); + let error = read_asset_canvas_draft_at( + fixture.root(), + &ReadAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + }, + ) + .expect_err("reject hardlinked sidecar"); + assert!(error.contains("reconciliation-required")); + fs::remove_file(hardlink).expect("remove hardlink fixture"); + fs::remove_file(&draft_path).expect("remove primary for symlink fixture"); + symlink( + "../../../../assets/missing.png", + fixture + .root() + .join(asset_canvas_draft_relative_path(&fixture.draft.draft_id)), + ) + .expect("symlink draft"); + assert!(read_asset_canvas_draft_at( + fixture.root(), + &ReadAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + }, + ) + .is_err()); + fs::remove_file( + fixture + .root() + .join(asset_canvas_draft_relative_path(&fixture.draft.draft_id)), + ) + .expect("remove symlink"); + let mut replaced = manifest; + replaced.project_id = "replacement-project".to_string(); + write_manifest(&manifest_path, &replaced).expect("replace project identity"); + let result = read_asset_canvas_draft_at( + fixture.root(), + &ReadAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + }, + ) + .expect("typed identity conflict"); + assert_eq!( + result.status, + ReadAssetCanvasDraftStatus::ProjectIdentityConflict + ); + } +} diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx new file mode 100644 index 000000000..2235f7c79 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx @@ -0,0 +1,1179 @@ +import { + type ChangeEvent, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { + createMinimapModel, + fitViewportToLayers, + MAX_HISTORY_STEPS, + moveViewportFromMinimapPointer, + moveViewportFromPan, + removeCanvasLayers, + resizeCanvasLayerBounds, + scaleViewportFromScreenPoint, + transformCanvasLayers, + type CanvasLayer, + type CanvasViewport, + type ImageCanvasDraft, + type ImageCanvasDraftCanvas, + type ImageCanvasHostScope, + type ImageCanvasMediaRef, +} from '@genarrative/image-canvas-core'; +import { + CanvasViewport as SharedCanvasViewport, + CanvasWorld, + LayerRenderer, + Minimap, + ZoomControls, +} from '@genarrative/image-canvas-react'; + +import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { + LocalAssetCommittedEvent, + TauriImageCanvasHostAdapter, +} from './tauriImageCanvasHostAdapter'; + +import './assetCanvasSurface.css'; + +export type AssetCanvasLifecycleState = + | { kind: 'canvas.creating' } + | { kind: 'canvas.editing'; dirty: boolean } + | { + kind: 'canvas.saving'; + stage: 'draft' | 'staging' | 'committing' | 'projecting'; + } + | { kind: 'canvas.recovering' } + | { + kind: 'canvas.failed'; + code: string; + message: string; + reconciliationRequired: boolean; + }; + +type RuntimeCanvasLayer = CanvasLayer & { mediaRef: ImageCanvasMediaRef }; +type HistorySnapshot = { + layers: RuntimeCanvasLayer[]; + viewport: CanvasViewport; + selectedLayerIds: string[]; +}; + +type DragState = + | { + kind: 'pan'; + pointerId: number; + startClientX: number; + startClientY: number; + startViewport: CanvasViewport; + } + | { + kind: 'move'; + startClientX: number; + startClientY: number; + startLayers: RuntimeCanvasLayer[]; + targetIds: string[]; + } + | { + kind: 'resize'; + startClientX: number; + startClientY: number; + startLayers: RuntimeCanvasLayer[]; + layerId: string; + }; + +export type RenderAssetCanvasImage = (input: { + layers: RuntimeCanvasLayer[]; + backgroundColor: string; + mediaType: 'image/png' | 'image/jpeg' | 'image/webp'; + quality: number | null; +}) => Promise; + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('图片预览无法用于导出')); + image.src = src; + }); +} + +export async function renderAssetCanvasImage({ + layers, + backgroundColor, + mediaType, + quality, +}: Parameters[0]): Promise { + const visible = layers.filter((layer) => !layer.hidden); + if (!visible.length) throw new Error('画布中没有可导出的图片'); + const minX = Math.floor(Math.min(...visible.map((layer) => layer.x))); + const minY = Math.floor(Math.min(...visible.map((layer) => layer.y))); + const maxX = Math.ceil( + Math.max(...visible.map((layer) => layer.x + layer.width)), + ); + const maxY = Math.ceil( + Math.max(...visible.map((layer) => layer.y + layer.height)), + ); + const width = Math.max(1, maxX - minX); + const height = Math.max(1, maxY - minY); + if ( + width > 16_384 || + height > 16_384 || + width * height > 268_435_456 + ) { + throw new Error('导出尺寸超过素材画布上限'); + } + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) throw new Error('当前 WebView 不支持画布导出'); + context.fillStyle = backgroundColor; + context.fillRect(0, 0, width, height); + for (const layer of [...visible].sort((a, b) => a.zIndex - b.zIndex)) { + const image = await loadImage(layer.src); + context.save(); + const centerX = layer.x - minX + layer.width / 2; + const centerY = layer.y - minY + layer.height / 2; + context.translate(centerX, centerY); + context.scale(layer.flipX ? -1 : 1, layer.flipY ? -1 : 1); + context.drawImage( + image, + -layer.width / 2, + -layer.height / 2, + layer.width, + layer.height, + ); + context.restore(); + } + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (value) => + value ? resolve(value) : reject(new Error('图片导出编码失败')), + mediaType, + quality ?? undefined, + ); + }); + if (blob.type && blob.type !== mediaType) { + throw new Error('当前 WebView 不支持所选导出格式'); + } + return new Uint8Array(await blob.arrayBuffer()); +} + +function draftCanvasFromRuntime( + layers: RuntimeCanvasLayer[], + viewport: CanvasViewport, + backgroundColor: string, + selectedLayerIds: string[], +): ImageCanvasDraftCanvas { + const visibleSelected = selectedLayerIds.filter((id) => + layers.some((layer) => layer.id === id && !layer.hidden), + ); + return { + viewport: { ...viewport }, + backgroundColor, + layers: layers.map((layer) => ({ + layerId: layer.id, + resourceId: layer.resourceId, + title: layer.title, + mediaRef: layer.mediaRef, + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + zIndex: layer.zIndex, + groupId: layer.groupId ?? null, + hidden: Boolean(layer.hidden), + locked: Boolean(layer.locked), + flipX: Boolean(layer.flipX), + flipY: Boolean(layer.flipY), + })), + selectedLayerIds: visibleSelected, + primarySelectedLayerId: visibleSelected.at(-1) ?? null, + }; +} + +function historyClone(input: HistorySnapshot): HistorySnapshot { + return { + layers: input.layers.map((layer) => ({ ...layer })), + viewport: { ...input.viewport }, + selectedLayerIds: [...input.selectedLayerIds], + }; +} + +function mediaTypeForFile(file: File) { + if (file.type === 'image/png') return 'image/png' as const; + if (file.type === 'image/jpeg') return 'image/jpeg' as const; + if (file.type === 'image/webp') return 'image/webp' as const; + return null; +} + +export function AssetCanvasSurface({ + host, + scope, + sessionId, + expectedHostRevision, + onCommitted, + renderImage = renderAssetCanvasImage, +}: { + host: TauriImageCanvasHostAdapter; + scope: ImageCanvasHostScope; + sessionId: string; + expectedHostRevision: string; + onCommitted?: (input: { + manifest: GameCreationAppManifest; + hostRevision: string; + resourceId: string; + eventId?: string; + }) => void; + renderImage?: RenderAssetCanvasImage; +}) { + const stableScope = useMemo( + () => ({ + projectId: scope.projectId, + draftId: scope.draftId, + intent: scope.intent, + sourceAssetId: scope.sourceAssetId, + }), + [scope.draftId, scope.intent, scope.projectId, scope.sourceAssetId], + ); + const [lifecycle, setLifecycle] = useState({ + kind: 'canvas.recovering', + }); + const [draft, setDraft] = useState(null); + const [layers, setLayers] = useState([]); + const [viewport, setViewport] = useState({ + x: 0, + y: 0, + scale: 0.5, + }); + const [backgroundColor, setBackgroundColor] = useState('#f4f4f5'); + const [selectedLayerIds, setSelectedLayerIds] = useState([]); + const [notice, setNotice] = useState(''); + const [assetName, setAssetName] = useState('画布素材'); + const [assetKind, setAssetKind] = useState('asset'); + const [exportMediaType, setExportMediaType] = useState< + 'image/png' | 'image/jpeg' | 'image/webp' + >('image/png'); + const [documentVersion, setDocumentVersion] = useState(0); + const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); + const viewportElementRef = useRef(null); + const layersRef = useRef(layers); + const viewportRef = useRef(viewport); + const backgroundRef = useRef(backgroundColor); + const selectionRef = useRef(selectedLayerIds); + const draftRef = useRef(draft); + const lifecycleRef = useRef(lifecycle); + const documentVersionRef = useRef(documentVersion); + const epochRef = useRef(0); + const dragRef = useRef(null); + const undoRef = useRef([]); + const redoRef = useRef([]); + const saveQueueRef = useRef>(Promise.resolve()); + const savePromiseRef = useRef | null>(null); + const hostRevisionRef = useRef(expectedHostRevision); + const previewUrlsRef = useRef(new Set()); + const deliveredEventsRef = useRef(new Set()); + const pendingCommitRef = useRef<{ + commitId: string; + idempotencyKey: string; + documentVersion: number; + } | null>(null); + + layersRef.current = layers; + viewportRef.current = viewport; + backgroundRef.current = backgroundColor; + selectionRef.current = selectedLayerIds; + draftRef.current = draft; + lifecycleRef.current = lifecycle; + documentVersionRef.current = documentVersion; + + const markDirty = useCallback(() => { + setDocumentVersion((value) => value + 1); + setLifecycle({ kind: 'canvas.editing', dirty: true }); + }, []); + + const currentSnapshot = useCallback( + (): HistorySnapshot => ({ + layers: layersRef.current.map((layer) => ({ ...layer })), + viewport: { ...viewportRef.current }, + selectedLayerIds: [...selectionRef.current], + }), + [], + ); + + const captureHistory = useCallback(() => { + undoRef.current = [ + ...undoRef.current.slice(-(MAX_HISTORY_STEPS - 1)), + historyClone(currentSnapshot()), + ]; + redoRef.current = []; + }, [currentSnapshot]); + + const applySnapshot = useCallback( + (snapshot: HistorySnapshot) => { + setLayers(snapshot.layers.map((layer) => ({ ...layer }))); + setViewport({ ...snapshot.viewport }); + setSelectedLayerIds([...snapshot.selectedLayerIds]); + markDirty(); + }, + [markDirty], + ); + + const undo = useCallback(() => { + const previous = undoRef.current.at(-1); + if (!previous) return; + redoRef.current = [ + ...redoRef.current.slice(-(MAX_HISTORY_STEPS - 1)), + historyClone(currentSnapshot()), + ]; + undoRef.current = undoRef.current.slice(0, -1); + applySnapshot(previous); + }, [applySnapshot, currentSnapshot]); + + const redo = useCallback(() => { + const next = redoRef.current.at(-1); + if (!next) return; + undoRef.current = [ + ...undoRef.current.slice(-(MAX_HISTORY_STEPS - 1)), + historyClone(currentSnapshot()), + ]; + redoRef.current = redoRef.current.slice(0, -1); + applySnapshot(next); + }, [applySnapshot, currentSnapshot]); + + const hydrateDraft = useCallback( + async (nextDraft: ImageCanvasDraft, epoch: number) => { + const runtimeLayers = await Promise.all( + nextDraft.canvas.layers.map(async (layer) => { + const preview = await host.readMediaPreview({ + scope: stableScope, + mediaRef: layer.mediaRef, + }); + if (preview.status !== 'ok') { + throw new Error( + preview.status === 'failed' || + preview.status === 'unsupported-capability' + ? preview.message + : '素材媒体读取发生冲突', + ); + } + if (epoch !== epochRef.current) { + URL.revokeObjectURL(preview.value.previewUrl); + throw new Error('stale-asset-canvas-epoch'); + } + previewUrlsRef.current.add(preview.value.previewUrl); + return { + id: layer.layerId, + resourceId: layer.resourceId, + resourcePersistenceState: 'self-contained-local' as const, + title: layer.title, + src: preview.value.previewUrl, + mediaType: 'image' as const, + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + zIndex: layer.zIndex, + sourceType: 'uploaded' as const, + groupId: layer.groupId, + hidden: layer.hidden, + locked: layer.locked, + flipX: layer.flipX, + flipY: layer.flipY, + mediaRef: layer.mediaRef, + } satisfies RuntimeCanvasLayer; + }), + ); + if (epoch !== epochRef.current) return; + setDraft(nextDraft); + setLayers(runtimeLayers); + setViewport(nextDraft.canvas.viewport); + setBackgroundColor(nextDraft.canvas.backgroundColor); + setSelectedLayerIds(nextDraft.canvas.selectedLayerIds); + undoRef.current = []; + redoRef.current = []; + setLifecycle({ kind: 'canvas.editing', dirty: false }); + }, + [host, stableScope], + ); + + useEffect(() => { + const epoch = epochRef.current + 1; + epochRef.current = epoch; + saveQueueRef.current = Promise.resolve(); + savePromiseRef.current = null; + pendingCommitRef.current = null; + hostRevisionRef.current = expectedHostRevision; + deliveredEventsRef.current.clear(); + setLifecycle({ kind: 'canvas.recovering' }); + setNotice(''); + let unlisten: (() => void) | undefined; + void (async () => { + const nextUnlisten = await host.subscribeCommitted((event) => { + if ( + epoch !== epochRef.current || + event.projectId !== stableScope.projectId || + event.draftId !== stableScope.draftId || + deliveredEventsRef.current.has(event.eventId) + ) { + return; + } + deliveredEventsRef.current.add(event.eventId); + onCommitted?.({ + manifest: event.manifest, + hostRevision: String(event.committedProjectRevision), + resourceId: event.asset.source.resourceId ?? event.asset.id, + eventId: event.eventId, + }); + }); + if (epoch !== epochRef.current) { + nextUnlisten(); + return; + } + unlisten = nextUnlisten; + const recovery = await host.recover(); + if (epoch !== epochRef.current) return; + if (recovery.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + code: recovery.status === 'failed' ? recovery.code : recovery.status, + message: + recovery.status === 'failed' + ? recovery.message + : '素材画布恢复发生冲突', + reconciliationRequired: + recovery.status === 'failed' && + recovery.code === 'reconciliation-required', + }); + return; + } + hostRevisionRef.current = String(recovery.value.projectRevision); + const loaded = await host.project.loadDraft(stableScope); + if (epoch !== epochRef.current) return; + let nextDraft: ImageCanvasDraft; + if (loaded.status === 'ok' && loaded.value) { + nextDraft = loaded.value; + } else if (loaded.status === 'ok') { + setLifecycle({ kind: 'canvas.creating' }); + const created = await host.project.createDraft(stableScope); + if (epoch !== epochRef.current) return; + if (created.status !== 'ok') { + throw new Error( + created.status === 'failed' + ? created.message + : '素材画布草稿创建冲突', + ); + } + nextDraft = created.value; + } else { + throw new Error( + loaded.status === 'failed' + ? loaded.message + : '素材画布草稿读取冲突', + ); + } + await hydrateDraft(nextDraft, epoch); + })().catch((error: unknown) => { + if ( + epoch === epochRef.current && + !(error instanceof Error && error.message === 'stale-asset-canvas-epoch') + ) { + setLifecycle({ + kind: 'canvas.failed', + code: 'canvas-open-failed', + message: error instanceof Error ? error.message : String(error), + reconciliationRequired: false, + }); + } + }); + return () => { + epochRef.current += 1; + unlisten?.(); + for (const url of previewUrlsRef.current) URL.revokeObjectURL(url); + previewUrlsRef.current.clear(); + }; + }, [ + expectedHostRevision, + host, + hydrateDraft, + onCommitted, + stableScope, + sessionId, + ]); + + useEffect(() => { + const element = viewportElementRef.current; + if (!element) return undefined; + const update = () => + setCanvasSize({ + width: element.clientWidth || 900, + height: element.clientHeight || 640, + }); + update(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + } + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, [draft]); + + const persistDraft = useCallback(async (): Promise => { + const epoch = epochRef.current; + const requestedVersion = documentVersion; + const task = saveQueueRef.current.then(async () => { + const currentDraft = draftRef.current; + if (!currentDraft || epoch !== epochRef.current) return null; + const result = await host.project.updateDraft({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + status: 'editing', + canvas: draftCanvasFromRuntime( + layersRef.current, + viewportRef.current, + backgroundRef.current, + selectionRef.current, + ), + generations: currentDraft.generations, + }); + if (epoch !== epochRef.current) return null; + if (result.status === 'conflict') { + setLifecycle({ + kind: 'canvas.failed', + code: 'draft-revision-conflict', + message: '草稿已被另一个窗口更新,请重新打开后继续', + reconciliationRequired: false, + }); + return null; + } + if (result.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' + ? result.message + : '草稿保存失败', + reconciliationRequired: false, + }); + return null; + } + draftRef.current = result.value; + setDraft(result.value); + if (requestedVersion === documentVersionRef.current) { + setLifecycle({ kind: 'canvas.editing', dirty: false }); + } + return result.value; + }); + saveQueueRef.current = task.catch(() => undefined); + return await task; + }, [documentVersion, host.project, stableScope]); + + useEffect(() => { + if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) { + return undefined; + } + const timer = window.setTimeout(() => void persistDraft(), 180); + return () => window.clearTimeout(timer); + }, [documentVersion, draft, lifecycle, persistDraft]); + + useEffect(() => { + const onMove = (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + if (drag.kind === 'pan') { + setViewport( + moveViewportFromPan({ + startViewport: drag.startViewport, + startClientX: drag.startClientX, + startClientY: drag.startClientY, + kind: 'pan', + pointerId: drag.pointerId, + }, { + x: event.clientX, + y: event.clientY, + }), + ); + return; + } + const deltaX = (event.clientX - drag.startClientX) / viewportRef.current.scale; + const deltaY = (event.clientY - drag.startClientY) / viewportRef.current.scale; + if (drag.kind === 'move') { + const transforms = new Map( + drag.startLayers + .filter((layer) => drag.targetIds.includes(layer.id)) + .map((layer) => [ + layer.id, + { x: layer.x + deltaX, y: layer.y + deltaY }, + ]), + ); + setLayers( + transformCanvasLayers(drag.startLayers, transforms) as RuntimeCanvasLayer[], + ); + } else { + const layer = drag.startLayers.find((item) => item.id === drag.layerId); + if (!layer) return; + const bounds = resizeCanvasLayerBounds({ + initial: layer, + handle: 'bottom-right', + deltaX, + deltaY, + preserveAspectRatio: !event.shiftKey, + minSize: 8, + }); + setLayers( + transformCanvasLayers( + drag.startLayers, + new Map([[drag.layerId, bounds]]), + ) as RuntimeCanvasLayer[], + ); + } + }; + const onUp = () => { + if (!dragRef.current) return; + dragRef.current = null; + markDirty(); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + return () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + }, [markDirty]); + + const handleImport = useCallback( + async (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ''; + const currentDraft = draftRef.current; + if (!currentDraft || !files.length) return; + const images = await Promise.all( + files.map(async (file) => { + const mediaType = mediaTypeForFile(file); + if (!mediaType) throw new Error('只支持 PNG、JPEG 和 WebP 图片'); + return { + name: file.name, + mediaType, + bytes: new Uint8Array(await file.arrayBuffer()), + }; + }), + ); + const epoch = epochRef.current; + const imported = await host.asset.importImages({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + images, + }); + if (epoch !== epochRef.current) { + if (imported.status === 'ok') { + for (const image of imported.value) URL.revokeObjectURL(image.previewUrl); + } + return; + } + if (imported.status !== 'ok') { + setNotice( + imported.status === 'failed' + ? imported.message + : '本地图片导入发生冲突', + ); + return; + } + captureHistory(); + const baseZ = layersRef.current.reduce( + (value, layer) => Math.max(value, layer.zIndex), + -1, + ); + const additions = imported.value.map((image, index) => { + previewUrlsRef.current.add(image.previewUrl); + const source = images[index]; + const layerId = crypto.randomUUID(); + return { + id: layerId, + resourceId: image.resourceId ?? `draft:${layerId}`, + resourcePersistenceState: 'self-contained-local' as const, + title: source?.name ?? `图片 ${index + 1}`, + src: image.previewUrl, + mediaType: 'image' as const, + x: 5800 + index * 36, + y: 5800 + index * 36, + width: + image.mediaRef.kind === 'draft-media' + ? image.mediaRef.pixelWidth + : 480, + height: + image.mediaRef.kind === 'draft-media' + ? image.mediaRef.pixelHeight + : 320, + originalWidth: + image.mediaRef.kind === 'draft-media' + ? image.mediaRef.pixelWidth + : 480, + originalHeight: + image.mediaRef.kind === 'draft-media' + ? image.mediaRef.pixelHeight + : 320, + zIndex: baseZ + index + 1, + sourceType: 'uploaded' as const, + groupId: null, + hidden: false, + locked: false, + flipX: false, + flipY: false, + mediaRef: image.mediaRef, + } satisfies RuntimeCanvasLayer; + }); + setLayers((current) => [...current, ...additions]); + setSelectedLayerIds(additions.map((layer) => layer.id)); + markDirty(); + setNotice(`已导入 ${additions.length} 张图片`); + }, + [captureHistory, host.asset, markDirty, stableScope], + ); + + const deleteSelected = useCallback(() => { + if (!selectionRef.current.length) return; + captureHistory(); + setLayers((current) => + removeCanvasLayers(current, selectionRef.current) as RuntimeCanvasLayer[], + ); + setSelectedLayerIds([]); + markDirty(); + }, [captureHistory, markDirty]); + + const saveAsset = useCallback(() => { + if (savePromiseRef.current) return savePromiseRef.current; + const saveEpoch = epochRef.current; + const task = (async () => { + const epoch = saveEpoch; + if (!draftRef.current) return; + if (lifecycleRef.current.kind === 'canvas.editing' && lifecycleRef.current.dirty) { + setLifecycle({ kind: 'canvas.saving', stage: 'draft' }); + const persisted = await persistDraft(); + if (!persisted) return; + } + if (epoch !== epochRef.current || !draftRef.current) return; + setLifecycle({ kind: 'canvas.saving', stage: 'staging' }); + const bytes = await renderImage({ + layers: layersRef.current, + backgroundColor: backgroundRef.current, + mediaType: exportMediaType, + quality: exportMediaType === 'image/png' ? null : 0.92, + }); + if (epoch !== epochRef.current) return; + const pending = + pendingCommitRef.current?.documentVersion === documentVersion + ? pendingCommitRef.current + : { + commitId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + documentVersion, + }; + pendingCommitRef.current = pending; + setLifecycle({ kind: 'canvas.saving', stage: 'committing' }); + const result = await host.completion.commitImage({ + scope: stableScope, + expectedHostRevision: hostRevisionRef.current, + expectedDraftRevision: draftRef.current.revision, + commitId: pending.commitId, + idempotencyKey: pending.idempotencyKey, + name: assetName, + assetKind, + referenceResourceIds: + stableScope.intent === 'refine' && draftRef.current.sourceResourceId + ? [draftRef.current.sourceResourceId] + : [], + mediaType: exportMediaType, + bytes, + }); + if (epoch !== epochRef.current) return; + if (result.status !== 'ok') { + const code = result.status === 'failed' ? result.code : result.status; + if (code === 'rolled-back') { + pendingCommitRef.current = null; + } + setLifecycle({ + kind: 'canvas.failed', + code, + message: + result.status === 'failed' + ? result.message + : '素材保存发生 revision 冲突', + reconciliationRequired: code === 'reconciliation-required', + }); + return; + } + hostRevisionRef.current = result.value.hostRevision; + if (draftRef.current) { + const next = { + ...draftRef.current, + revision: result.value.draftRevision, + status: 'committed' as const, + }; + setDraft(next); + } + setLifecycle({ kind: 'canvas.saving', stage: 'projecting' }); + const manifest = result.value.manifest as GameCreationAppManifest | undefined; + if (manifest) { + if (result.value.eventId) { + deliveredEventsRef.current.add(result.value.eventId); + } + onCommitted?.({ + manifest, + hostRevision: result.value.hostRevision, + resourceId: result.value.resourceId, + eventId: result.value.eventId, + }); + } + pendingCommitRef.current = null; + setNotice( + result.value.commitStatus === 'already-committed' + ? '素材已提交,本次返回原幂等结果' + : '素材已保存到项目 assets/ 并登记 manifest', + ); + setLifecycle({ kind: 'canvas.editing', dirty: false }); + })().catch((error: unknown) => { + if (epochRef.current !== saveEpoch) return; + setLifecycle({ + kind: 'canvas.failed', + code: 'canvas-save-failed', + message: error instanceof Error ? error.message : String(error), + reconciliationRequired: false, + }); + }); + savePromiseRef.current = task.finally(() => { + savePromiseRef.current = null; + }); + return savePromiseRef.current; + }, [ + assetKind, + assetName, + documentVersion, + exportMediaType, + host.completion, + onCommitted, + persistDraft, + renderImage, + stableScope, + ]); + + const runMockGeneration = useCallback(() => { + const currentDraft = draftRef.current; + if (!currentDraft) return; + const epoch = epochRef.current; + void host.generation + .generateImage({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + generationId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + prompt: 'mock', + referenceResourceIds: [], + }) + .then((result) => { + if (epoch !== epochRef.current) return; + setNotice( + result.status === 'unsupported-capability' + ? result.message + : 'Mock 生成已返回', + ); + }) + .catch((error: unknown) => { + if (epoch !== epochRef.current) return; + setNotice(error instanceof Error ? error.message : String(error)); + }); + }, [host.generation, stableScope]); + + const minimapModel = useMemo( + () => createMinimapModel({ layers, viewport, canvasSize }), + [canvasSize, layers, viewport], + ); + + if (!draft || lifecycle.kind === 'canvas.recovering' || lifecycle.kind === 'canvas.creating') { + return ( +
+ {lifecycle.kind === 'canvas.recovering' ? '正在恢复画布…' : '正在创建画布…'} +
+ ); + } + + return ( +
+
+ + + + + + + setAssetName(event.target.value)} + /> + setAssetKind(event.target.value)} + /> + + +
+ + { + if (event.target !== event.currentTarget) return; + captureHistory(); + setSelectedLayerIds([]); + dragRef.current = { + kind: 'pan', + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startViewport: { ...viewportRef.current }, + }; + }} + onWheel={(event) => { + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + setViewport((current) => + scaleViewportFromScreenPoint({ + viewport: current, + nextScale: current.scale * (event.deltaY < 0 ? 1.12 : 0.88), + screenPoint: { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }, + }), + ); + markDirty(); + }} + > + + {layers.map((layer) => { + const selected = selectedLayerIds.includes(layer.id); + return ( + ) => { + event.stopPropagation(); + const targetIds = event.shiftKey + ? selected + ? selectedLayerIds.filter((id) => id !== layer.id) + : [...selectedLayerIds, layer.id] + : selected + ? selectedLayerIds + : [layer.id]; + setSelectedLayerIds(targetIds); + if (layer.locked) { + if ( + targetIds.length !== selectedLayerIds.length || + targetIds.some((id, index) => id !== selectedLayerIds[index]) + ) { + markDirty(); + } + return; + } + captureHistory(); + dragRef.current = { + kind: 'move', + startClientX: event.clientX, + startClientY: event.clientY, + startLayers: layersRef.current.map((item) => ({ ...item })), + targetIds, + }; + }} + > + + {selected && !layer.locked ? ( + { + event.stopPropagation(); + captureHistory(); + dragRef.current = { + kind: 'resize', + startClientX: event.clientX, + startClientY: event.clientY, + startLayers: layersRef.current.map((item) => ({ ...item })), + layerId: layer.id, + }; + }} + /> + ) : null} + + ); + })} + + + + + +
+ {lifecycle.kind} + {lifecycle.kind === 'canvas.saving' ? {lifecycle.stage} : null} + {lifecycle.kind === 'canvas.failed' ? {lifecycle.message} : null} + {notice ? {notice} : null} +
+
+ ); +} + +export function createAssetCanvasFixtureScope(input?: { + projectId?: string; + draftId?: string; + intent?: 'create' | 'refine'; + sourceAssetId?: string | null; +}): ImageCanvasHostScope { + return { + projectId: input?.projectId ?? 'asset-canvas-fixture', + draftId: input?.draftId ?? crypto.randomUUID(), + intent: input?.intent ?? 'create', + sourceAssetId: input?.sourceAssetId ?? null, + }; +} + +export type { LocalAssetCommittedEvent }; diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css b/apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css new file mode 100644 index 000000000..c52e3244f --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css @@ -0,0 +1,161 @@ +.asset-canvas-surface { + position: relative; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: 100%; + height: min(800px, 100vh); + min-height: 540px; + overflow: hidden; + border-radius: 16px; + background: #e8e6e2; + color: #2d2926; +} + +.asset-canvas-surface--loading { + place-items: center; +} + +.asset-canvas-surface__toolbar { + z-index: 4; + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid rgba(63, 54, 48, 0.12); + background: rgba(252, 251, 249, 0.96); +} + +.asset-canvas-surface button, +.asset-canvas-surface input, +.asset-canvas-surface select, +.asset-canvas-surface__import { + min-height: 34px; + border: 1px solid rgba(63, 54, 48, 0.18); + border-radius: 9px; + background: #fff; + color: inherit; + font: inherit; +} + +.asset-canvas-surface button, +.asset-canvas-surface__import { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 11px; + cursor: pointer; +} + +.asset-canvas-surface button:disabled { + cursor: default; + opacity: 0.45; +} + +.asset-canvas-surface input, +.asset-canvas-surface select { + min-width: 0; + padding: 0 9px; +} + +.asset-canvas-surface__import input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} + +.asset-canvas-surface__spacer { + flex: 1; +} + +.asset-canvas-surface__save { + border-color: #c7653d !important; + background: #c7653d !important; + color: #fff !important; +} + +.asset-canvas-surface__viewport { + grid-row: 2; + min-height: 0; + background-image: radial-gradient(rgba(45, 41, 38, 0.12) 1px, transparent 1px); + background-size: 18px 18px; +} + +.asset-canvas-surface__resize-handle { + position: absolute; + right: -8px; + bottom: -8px; + width: 16px; + height: 16px; + border: 2px solid #fff; + border-radius: 50%; + background: #c7653d; + cursor: nwse-resize; +} + +.asset-canvas-surface__viewport-tools { + position: absolute; + z-index: 5; + right: 14px; + bottom: 46px; + display: grid; + gap: 8px; +} + +.asset-canvas-surface__zoom { + display: flex; + justify-content: flex-end; + gap: 4px; +} + +.asset-canvas-surface__zoom button { + min-height: 30px; + background: rgba(255, 255, 255, 0.94); +} + +.asset-canvas-surface .genarrative-image-canvas__minimap { + width: 132px; + height: 84px; + padding: 0; + overflow: hidden; + background: rgba(255, 255, 255, 0.94); +} + +.asset-canvas-surface .genarrative-image-canvas__minimap-layer { + background: rgba(199, 101, 61, 0.32); +} + +.asset-canvas-surface .genarrative-image-canvas__minimap-viewport { + border: 1px solid #c7653d; +} + +.asset-canvas-surface__status { + z-index: 4; + display: flex; + min-height: 34px; + align-items: center; + gap: 12px; + padding: 0 12px; + border-top: 1px solid rgba(63, 54, 48, 0.12); + background: rgba(252, 251, 249, 0.96); + font-size: 12px; +} + +@media (max-width: 760px) { + .asset-canvas-surface { + height: 100dvh; + min-height: 480px; + border-radius: 0; + } + + .asset-canvas-surface__toolbar { + overflow-x: auto; + } + + .asset-canvas-surface__toolbar input { + width: 112px; + flex: 0 0 auto; + } +} diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts b/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts new file mode 100644 index 000000000..479747e91 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts @@ -0,0 +1,518 @@ +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +import type { + ImageCanvasDraft, + ImageCanvasGenerationPort, + ImageCanvasHostPort, + ImageCanvasHostResult, + ImageCanvasHostScope, + ImageCanvasMediaRef, +} from '@genarrative/image-canvas-core'; + +import type { + GameCreationAppAssetManifestEntry, + GameCreationAppManifest, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; + +const MAX_SAFE_REVISION = Number.MAX_SAFE_INTEGER; +export const LOCAL_ASSET_COMMITTED_EVENT = + 'game-creator-local-asset-committed' as const; + +type Invoke = (command: string, args?: Record) => Promise; + +type DraftReadResult = + | { status: 'found'; draft: ImageCanvasDraft } + | { status: 'not-found'; draft: null } + | { status: 'project-identity-conflict'; draft: null }; + +type DraftCreateResult = { + status: 'created' | 'existing'; + draft: ImageCanvasDraft; +}; + +type DraftUpdateResult = { + status: 'updated' | 'conflict'; + draft: ImageCanvasDraft; +}; + +type StoredMediaResult = { + status: 'stored' | 'conflict'; + draftId: string; + draftRevision: number; + mediaRef: ImageCanvasMediaRef | null; + draft: ImageCanvasDraft | null; +}; + +type StagedImageResult = { + status: 'staged' | 'conflict'; + stagedImageToken: string | null; + draftId: string; + draftRevision: number; + draft: ImageCanvasDraft | null; +}; + +export type LocalAssetCommitResult = + | { + status: 'committed' | 'already-committed'; + projectId: string; + projectRevision: number; + committedProjectRevision: number; + draftId: string; + draftRevision: number; + commitId: string; + idempotencyKey: string; + eventId: string; + asset: GameCreationAppAssetManifestEntry; + manifest: GameCreationAppManifest; + } + | { + status: 'conflict'; + conflictKind: + | 'project-identity' + | 'project-revision' + | 'draft-revision'; + projectRevision: number | null; + draftRevision: number | null; + manifest: GameCreationAppManifest | null; + } + | { + status: 'rolled-back' | 'reconciliation-required'; + projectId: string; + projectRevision: number; + draftId: string; + draftRevision: number; + eventId: string; + manifest: GameCreationAppManifest; + }; + +export type LocalAssetCommittedEvent = { + schemaVersion: 'game-creator-local-asset-committed.v1'; + eventId: string; + projectPath: string; + projectId: string; + committedProjectRevision: number; + draftId: string; + commitId: string; + idempotencyKey: string; + asset: GameCreationAppAssetManifestEntry; + manifest: GameCreationAppManifest; + occurredAt: number; +}; + +export type TauriImageCanvasHostAdapter = ImageCanvasHostPort & { + readonly kind: 'tauri'; + readonly projectPath: string; + readonly expectedProjectId: string; + readMediaPreview(input: { + scope: ImageCanvasHostScope; + mediaRef: ImageCanvasMediaRef; + }): Promise>; + recover(): Promise< + ImageCanvasHostResult<{ + projectRevision: number; + manifest: GameCreationAppManifest; + }> + >; + subscribeCommitted( + callback: (event: LocalAssetCommittedEvent) => void, + ): Promise; +}; + +function safeRevision(value: unknown, label: string): number { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < 0 || + value > MAX_SAFE_REVISION + ) { + throw new Error(`${label} 不是安全 revision`); + } + return value; +} + +function safeRevisionString(value: string, label: string): number { + if (!/^(0|[1-9]\d*)$/.test(value)) { + throw new Error(`${label} 不是规范十进制 revision`); + } + return safeRevision(Number(value), label); +} + +function failure(error: unknown): ImageCanvasHostResult { + const message = error instanceof Error ? error.message : String(error); + return { + status: 'failed', + code: message.includes('reconciliation-required') + ? 'reconciliation-required' + : message.includes('project-identity-conflict') + ? 'project-identity-conflict' + : 'tauri-command-failed', + message, + }; +} + +function conflict( + kind: 'project-identity' | 'host-revision' | 'draft-revision', + draft: ImageCanvasDraft | null, + hostRevision: number | null, +): ImageCanvasHostResult { + return { + status: 'conflict', + conflictKind: kind, + draft, + hostRevision: hostRevision === null ? null : String(hostRevision), + }; +} + +function bytesToPreviewUrl( + bytes: Uint8Array | number[], + mediaType: string, +): string { + return URL.createObjectURL( + new Blob([new Uint8Array(bytes)], { type: mediaType }), + ); +} + +export function createMockImageCanvasGenerationPort(): ImageCanvasGenerationPort { + const unsupported = async () => ({ + status: 'unsupported-capability' as const, + capability: 'externalEditorGeneration' as const, + message: '当前阶段使用明确 mock,不会提交真实 AI 生成请求', + }); + return { + generateImage: unsupported, + removeBackground: unsupported, + }; +} + +export function createTauriImageCanvasHostAdapter(input: { + projectPath: string; + expectedProjectId: string; + expectedHostRevision: number; + invoke: Invoke; + listenEvent?: typeof listen; +}): TauriImageCanvasHostAdapter { + const invokeInput = (command: string, value: Record) => + input.invoke(command, { input: value }); + const baseScope = (scope: ImageCanvasHostScope) => { + if (scope.projectId !== input.expectedProjectId) { + throw new Error( + 'project-identity-conflict: 画布 scope 与宿主 expectedProjectId 不一致', + ); + } + return { + projectPath: input.projectPath, + expectedProjectId: input.expectedProjectId, + draftId: scope.draftId, + }; + }; + const generation = createMockImageCanvasGenerationPort(); + const stagedByCommit = new Map< + string, + { + idempotencyKey: string; + expectedDraftRevision: number; + mediaType: 'image/png' | 'image/jpeg' | 'image/webp'; + byteLength: number; + stagedImageToken: string; + } + >(); + + return { + kind: 'tauri', + projectPath: input.projectPath, + expectedProjectId: input.expectedProjectId, + capabilities: { + account: false, + wallet: false, + cloudAssetLibrary: false, + localProject: true, + externalEditorGeneration: false, + advancedBackgroundRemoval: false, + }, + project: { + async loadDraft(scope) { + try { + const result = await invokeInput( + 'read_local_project_asset_canvas_draft', + baseScope(scope), + ); + if (result.status === 'project-identity-conflict') { + return conflict('project-identity', null, null); + } + return { + status: 'ok', + value: result.status === 'found' ? result.draft : null, + }; + } catch (error) { + return failure(error); + } + }, + async createDraft(scope) { + try { + const result = await invokeInput( + 'create_local_project_asset_canvas_draft', + { + ...baseScope(scope), + intent: scope.intent, + sourceAssetId: scope.sourceAssetId, + }, + ); + return { status: 'ok', value: result.draft }; + } catch (error) { + return failure(error); + } + }, + async updateDraft(update) { + try { + const result = await invokeInput( + 'update_local_project_asset_canvas_draft', + { + ...baseScope(update.scope), + expectedDraftRevision: safeRevision( + update.expectedDraftRevision, + 'expectedDraftRevision', + ), + status: update.status, + canvas: update.canvas, + generations: update.generations, + }, + ); + if (result.status === 'conflict') { + return conflict('draft-revision', result.draft, null); + } + return { status: 'ok', value: result.draft }; + } catch (error) { + return failure(error); + } + }, + async discardDraft(update) { + try { + const result = await invokeInput( + 'discard_local_project_asset_canvas_draft', + { + ...baseScope(update.scope), + expectedDraftRevision: safeRevision( + update.expectedDraftRevision, + 'expectedDraftRevision', + ), + }, + ); + if (result.status === 'conflict') { + return conflict('draft-revision', result.draft, null); + } + return { status: 'ok', value: result.draft }; + } catch (error) { + return failure(error); + } + }, + }, + asset: { + async importImages(importInput) { + const images = []; + try { + for (const image of importInput.images) { + const result = await invokeInput( + 'store_local_project_asset_canvas_media', + { + ...baseScope(importInput.scope), + expectedDraftRevision: safeRevision( + importInput.expectedDraftRevision, + 'expectedDraftRevision', + ), + mediaType: image.mediaType, + bytes: Array.from(image.bytes), + }, + ); + if (result.status === 'conflict') { + return conflict('draft-revision', result.draft, null); + } + if (!result.mediaRef || result.mediaRef.kind !== 'draft-media') { + throw new Error('Tauri 未返回受控草稿媒体引用'); + } + images.push({ + mediaRef: result.mediaRef, + previewUrl: bytesToPreviewUrl(image.bytes, image.mediaType), + resourceId: `draft-media:${result.mediaRef.mediaId}`, + }); + } + return { status: 'ok', value: images }; + } catch (error) { + for (const image of images) URL.revokeObjectURL(image.previewUrl); + return failure(error); + } + }, + async exportImage() { + return { + status: 'failed', + code: 'commit-required', + message: 'Tauri 素材导出必须通过 completion port 完成 staging 与正式提交', + }; + }, + }, + generation, + completion: { + async commitImage(commitInput) { + try { + const expectedDraftRevision = safeRevision( + commitInput.expectedDraftRevision, + 'expectedDraftRevision', + ); + const expectedRevision = safeRevisionString( + commitInput.expectedHostRevision, + 'expectedHostRevision', + ); + let stagedIdentity = stagedByCommit.get(commitInput.commitId); + if (stagedIdentity) { + if ( + stagedIdentity.idempotencyKey !== commitInput.idempotencyKey || + stagedIdentity.expectedDraftRevision !== expectedDraftRevision || + stagedIdentity.mediaType !== commitInput.mediaType || + stagedIdentity.byteLength !== commitInput.bytes.byteLength + ) { + throw new Error('commitId 已绑定到不同的 staging 请求'); + } + } else { + const staged = await invokeInput( + 'stage_local_project_asset_canvas_image', + { + ...baseScope(commitInput.scope), + expectedDraftRevision, + mediaType: commitInput.mediaType, + bytes: Array.from(commitInput.bytes), + }, + ); + if (staged.status === 'conflict' || !staged.stagedImageToken) { + return conflict('draft-revision', staged.draft, null); + } + stagedIdentity = { + idempotencyKey: commitInput.idempotencyKey, + expectedDraftRevision, + mediaType: commitInput.mediaType, + byteLength: commitInput.bytes.byteLength, + stagedImageToken: staged.stagedImageToken, + }; + stagedByCommit.set(commitInput.commitId, stagedIdentity); + } + const result = await invokeInput( + 'commit_local_project_asset', + { + ...baseScope(commitInput.scope), + expectedRevision, + expectedDraftRevision, + commitId: commitInput.commitId, + idempotencyKey: commitInput.idempotencyKey, + intent: commitInput.scope.intent, + sourceAssetId: commitInput.scope.sourceAssetId, + stagedImageToken: stagedIdentity.stagedImageToken, + name: commitInput.name, + assetKind: commitInput.assetKind, + referenceResourceIds: commitInput.referenceResourceIds, + generationProvenance: null, + }, + ); + if (result.status === 'conflict') { + return conflict( + result.conflictKind === 'project-revision' + ? 'host-revision' + : result.conflictKind, + null, + result.projectRevision, + ); + } + if ( + result.status === 'rolled-back' || + result.status === 'reconciliation-required' + ) { + return { + status: 'failed', + code: result.status, + message: + result.status === 'rolled-back' + ? '素材保存已安全回滚' + : '素材保存需要恢复或人工对账', + }; + } + if ( + result.status !== 'committed' && + result.status !== 'already-committed' + ) { + return failure('素材保存返回未知状态'); + } + return { + status: 'ok', + value: { + resourceId: result.asset.source.resourceId ?? result.asset.id, + draftRevision: result.draftRevision, + hostRevision: String(result.projectRevision), + commitStatus: result.status, + manifest: result.manifest, + eventId: result.eventId, + }, + }; + } catch (error) { + return failure(error); + } + }, + }, + async readMediaPreview(readInput) { + try { + const result = await invokeInput<{ + mediaType: string; + bytes: number[]; + }>('read_local_project_asset_canvas_media', { + ...baseScope(readInput.scope), + mediaRef: readInput.mediaRef, + }); + return { + status: 'ok', + value: { + previewUrl: bytesToPreviewUrl(result.bytes, result.mediaType), + }, + }; + } catch (error) { + return failure(error); + } + }, + async recover() { + try { + const result = await invokeInput<{ + projectRevision: number; + manifest: GameCreationAppManifest; + outcomes: Array<{ status: string }>; + }>('recover_local_project_asset_canvas_transactions', { + projectPath: input.projectPath, + expectedProjectId: input.expectedProjectId, + }); + safeRevision(result.projectRevision, 'projectRevision'); + if ( + result.outcomes?.some( + (outcome) => outcome.status === 'reconciliation-required', + ) + ) { + return { + status: 'failed', + code: 'reconciliation-required', + message: '素材画布存在无法自动证明的未完成事务', + }; + } + return { status: 'ok', value: result }; + } catch (error) { + return failure(error); + } + }, + async subscribeCommitted(callback) { + const listenEvent = input.listenEvent ?? listen; + return listenEvent( + LOCAL_ASSET_COMMITTED_EVENT, + (event) => { + if ( + event.payload.projectPath === input.projectPath && + event.payload.projectId === input.expectedProjectId + ) { + callback(event.payload); + } + }, + ); + }, + }; +} diff --git a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx new file mode 100644 index 000000000..d7fef72d4 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx @@ -0,0 +1,704 @@ +/** @vitest-environment jsdom */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + ImageCanvasDraft, + ImageCanvasDraftCanvas, + ImageCanvasHostScope, +} from '@genarrative/image-canvas-core'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + AssetCanvasSurface, + type RenderAssetCanvasImage, +} from '../src/features/asset-canvas/AssetCanvasSurface'; +import { + createMockImageCanvasGenerationPort, + createTauriImageCanvasHostAdapter, + type LocalAssetCommittedEvent, + type TauriImageCanvasHostAdapter, +} from '../src/features/asset-canvas/tauriImageCanvasHostAdapter'; + +class TestPointerEvent extends MouseEvent { + readonly pointerId: number; + + constructor(type: string, init: MouseEventInit & { pointerId?: number } = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 1; + } +} + +const scope: ImageCanvasHostScope = { + projectId: 'project-one', + draftId: '11111111-1111-4111-8111-111111111111', + intent: 'create', + sourceAssetId: null, +}; + +function emptyCanvas(): ImageCanvasDraftCanvas { + return { + viewport: { x: 0, y: 0, scale: 0.5 }, + backgroundColor: '#f4f4f5', + layers: [], + selectedLayerIds: [], + primarySelectedLayerId: null, + }; +} + +function draftFixture( + draftScope: ImageCanvasHostScope = scope, + canvas: ImageCanvasDraftCanvas = emptyCanvas(), +): ImageCanvasDraft { + return { + schemaVersion: 'game-creator-asset-canvas-draft.v1', + draftId: draftScope.draftId, + projectId: draftScope.projectId, + intent: draftScope.intent, + sourceAssetId: draftScope.sourceAssetId, + sourceResourceId: + draftScope.intent === 'refine' ? 'local-asset:source-asset' : null, + revision: 0, + status: 'editing', + canvas, + generations: [], + pendingCommit: null, + lastCommit: null, + createdAt: 1, + updatedAt: 1, + }; +} + +function manifestFixture(projectId: string): GameCreationAppManifest { + return { + schemaVersion: 'game-creation-app-manifest.v1', + projectId, + name: '素材画布测试', + assets: [ + { + id: 'canvas-commit', + kind: 'illustration', + mediaType: 'image/png', + localPath: 'assets/canvas/result.png', + source: { + kind: 'canvas', + resourceId: 'local-asset:canvas-commit', + referenceResourceIds: [], + }, + }, + ], + } as unknown as GameCreationAppManifest; +} + +type Deferred = { + promise: Promise; + resolve(value: T): void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + return { + promise: new Promise((done) => { + resolve = done; + }), + resolve, + }; +} + +function memoryHost(input?: { + initialDraft?: ImageCanvasDraft | null; + recoverGate?: Deferred; + commitGate?: Deferred; + generationGate?: Deferred; + recoveryEvent?: LocalAssetCommittedEvent; +}) { + let draft = input?.initialDraft ?? null; + let hostRevision = 0; + let eventListener: ((event: LocalAssetCommittedEvent) => void) | null = null; + const updates: ImageCanvasDraftCanvas[] = []; + const commits: Array<{ commitId: string; idempotencyKey: string }> = []; + const revokedSubscriptions = vi.fn(); + const manifest = manifestFixture(draft?.projectId ?? scope.projectId); + const loadDraft = vi.fn(async () => ({ status: 'ok' as const, value: draft })); + const recover = vi.fn(async () => { + await input?.recoverGate?.promise; + if (input?.recoveryEvent) eventListener?.(input.recoveryEvent); + return { status: 'ok' as const, value: { projectRevision: hostRevision, manifest } }; + }); + const host: TauriImageCanvasHostAdapter = { + kind: 'tauri', + projectPath: '/fixture/project', + expectedProjectId: draft?.projectId ?? scope.projectId, + capabilities: { + account: false, + wallet: false, + cloudAssetLibrary: false, + localProject: true, + externalEditorGeneration: false, + advancedBackgroundRemoval: false, + }, + project: { + loadDraft, + async createDraft(nextScope) { + draft = draftFixture(nextScope); + return { status: 'ok', value: draft }; + }, + async updateDraft(update) { + if (!draft || update.expectedDraftRevision !== draft.revision) { + return { + status: 'conflict', + conflictKind: 'draft-revision', + draft, + hostRevision: String(hostRevision), + }; + } + updates.push(update.canvas); + draft = { + ...draft, + revision: draft.revision + 1, + status: update.status, + canvas: update.canvas, + generations: update.generations, + updatedAt: draft.updatedAt + 1, + }; + return { status: 'ok', value: draft }; + }, + async discardDraft() { + if (!draft) throw new Error('draft missing'); + draft = { ...draft, revision: draft.revision + 1, status: 'cancelled' }; + return { status: 'ok', value: draft }; + }, + }, + asset: { + async importImages(importInput) { + return { + status: 'ok', + value: importInput.images.map((image, index) => ({ + mediaRef: { + kind: 'draft-media' as const, + mediaId: `media-${index}`, + mediaType: image.mediaType, + sha256: 'a'.repeat(64), + byteLength: image.bytes.length, + pixelWidth: 64, + pixelHeight: 48, + }, + previewUrl: `blob:import-${index}`, + resourceId: `draft-media:media-${index}`, + })), + }; + }, + async exportImage() { + return { + status: 'failed', + code: 'commit-required', + message: 'commit required', + }; + }, + }, + generation: input?.generationGate + ? { + async generateImage() { + await input.generationGate?.promise; + return { + status: 'unsupported-capability' as const, + capability: 'externalEditorGeneration' as const, + message: '迟到的 mock generation', + }; + }, + async removeBackground() { + return { + status: 'unsupported-capability' as const, + capability: 'advancedBackgroundRemoval' as const, + message: '当前阶段不接真实去背景', + }; + }, + } + : createMockImageCanvasGenerationPort(), + completion: { + async commitImage(commitInput) { + commits.push({ + commitId: commitInput.commitId, + idempotencyKey: commitInput.idempotencyKey, + }); + await input?.commitGate?.promise; + hostRevision += 1; + if (!draft) throw new Error('draft missing'); + draft = { ...draft, revision: draft.revision + 1, status: 'committed' }; + return { + status: 'ok', + value: { + resourceId: 'local-asset:canvas-commit', + draftRevision: draft.revision, + hostRevision: String(hostRevision), + commitStatus: 'committed', + manifest, + eventId: 'event-one', + }, + }; + }, + }, + async readMediaPreview() { + return { status: 'ok', value: { previewUrl: 'blob:restored' } }; + }, + recover, + async subscribeCommitted(callback) { + eventListener = callback; + return () => { + eventListener = null; + revokedSubscriptions(); + }; + }, + }; + return { + host, + commits, + updates, + revokedSubscriptions, + loadDraft, + recover, + getDraft: () => draft, + emit(event: LocalAssetCommittedEvent) { + eventListener?.(event); + }, + }; +} + +const renderImage: RenderAssetCanvasImage = vi.fn(async () => + Uint8Array.from([137, 80, 78, 71]), +); + +function renderSurface( + host: TauriImageCanvasHostAdapter, + canvasScope: ImageCanvasHostScope = scope, + onCommitted = vi.fn(), +) { + return { + onCommitted, + ...render( +
+ +
, + ), + }; +} + +beforeEach(() => { + Object.defineProperty(window, 'PointerEvent', { + configurable: true, + value: TestPointerEvent, + }); + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:fixture'), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('Tauri 素材创作无限画布独立 Surface', () => { + it('新建、导入、编辑、撤销重做并完成 durable commit', async () => { + const memory = memoryHost(); + const { onCommitted } = renderSurface(memory.host); + expect(await screen.findByText('canvas.editing')).toBeTruthy(); + + const file = new File([Uint8Array.from([1, 2, 3])], 'fixture.png', { + type: 'image/png', + }); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: async () => Uint8Array.from([1, 2, 3]).buffer, + }); + fireEvent.change(screen.getByLabelText('导入本地图片'), { + target: { files: [file] }, + }); + expect(await screen.findByLabelText('图层 fixture.png')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '删除' })); + await waitFor(() => + expect(screen.queryByLabelText('图层 fixture.png')).toBeNull(), + ); + fireEvent.click(screen.getByRole('button', { name: '撤销' })); + expect(await screen.findByLabelText('图层 fixture.png')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '重做' })); + await waitFor(() => + expect(screen.queryByLabelText('图层 fixture.png')).toBeNull(), + ); + fireEvent.click(screen.getByRole('button', { name: '撤销' })); + + fireEvent.click(screen.getByRole('button', { name: '保存到项目' })); + await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1)); + expect(memory.commits).toHaveLength(1); + expect(memory.updates.at(-1)?.layers).toHaveLength(1); + expect(screen.getByText(/已保存到项目 assets/)).toBeTruthy(); + }); + + it('保存连点保持 single-flight,命令事件重放不重复投影', async () => { + const commitGate = deferred(); + const canvas = emptyCanvas(); + canvas.layers.push({ + layerId: 'layer-one', + resourceId: 'draft-media:one', + title: '已有图层', + mediaRef: { + kind: 'draft-media', + mediaId: 'one', + mediaType: 'image/png', + sha256: 'b'.repeat(64), + byteLength: 4, + pixelWidth: 4, + pixelHeight: 3, + }, + x: 0, + y: 0, + width: 4, + height: 3, + originalWidth: 4, + originalHeight: 3, + zIndex: 0, + groupId: null, + hidden: false, + locked: false, + flipX: false, + flipY: false, + }); + const memory = memoryHost({ initialDraft: draftFixture(scope, canvas), commitGate }); + const { onCommitted } = renderSurface(memory.host); + await screen.findByLabelText('图层 已有图层'); + const save = screen.getByRole('button', { name: '保存到项目' }); + fireEvent.click(save); + fireEvent.click(save); + await waitFor(() => expect(memory.commits).toHaveLength(1)); + commitGate.resolve(); + await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1)); + memory.emit({ + schemaVersion: 'game-creator-local-asset-committed.v1', + eventId: 'event-one', + projectPath: '/moved/project', + projectId: scope.projectId, + committedProjectRevision: 1, + draftId: scope.draftId, + commitId: memory.commits[0]!.commitId, + idempotencyKey: memory.commits[0]!.idempotencyKey, + asset: manifestFixture(scope.projectId).assets[0]!, + manifest: manifestFixture(scope.projectId), + occurredAt: 1, + }); + expect(onCommitted).toHaveBeenCalledTimes(1); + }); + + it('重启恢复现有草稿,并在项目切换后丢弃旧 Promise 和事件', async () => { + const oldScope = scope; + const oldCanvas = emptyCanvas(); + oldCanvas.layers.push({ + layerId: 'old-layer', + resourceId: 'old-resource', + title: '旧项目图层', + mediaRef: { kind: 'project-asset', assetId: 'old-asset' }, + x: 0, + y: 0, + width: 100, + height: 100, + originalWidth: 100, + originalHeight: 100, + zIndex: 0, + groupId: null, + hidden: false, + locked: false, + flipX: false, + flipY: false, + }); + const gate = deferred(); + const oldMemory = memoryHost({ + initialDraft: draftFixture(oldScope, oldCanvas), + recoverGate: gate, + }); + const nextScope: ImageCanvasHostScope = { + ...scope, + projectId: 'project-two', + draftId: '33333333-3333-4333-8333-333333333333', + }; + const nextMemory = memoryHost({ initialDraft: draftFixture(nextScope) }); + const onCommitted = vi.fn(); + const view = renderSurface(oldMemory.host, oldScope, onCommitted); + view.rerender( + , + ); + expect(await screen.findByText('canvas.editing')).toBeTruthy(); + await act(async () => gate.resolve(undefined)); + expect(screen.queryByLabelText('图层 旧项目图层')).toBeNull(); + oldMemory.emit({ + schemaVersion: 'game-creator-local-asset-committed.v1', + eventId: 'old-event', + projectPath: '/old', + projectId: oldScope.projectId, + committedProjectRevision: 1, + draftId: oldScope.draftId, + commitId: 'old-commit', + idempotencyKey: 'old-key', + asset: manifestFixture(oldScope.projectId).assets[0]!, + manifest: manifestFixture(oldScope.projectId), + occurredAt: 1, + }); + expect(onCommitted).not.toHaveBeenCalled(); + }); + + it('明确显示 mock generation,小地图/复位和 1280×800 基础布局可用', async () => { + const canvas = emptyCanvas(); + canvas.layers.push({ + layerId: 'layout-layer', + resourceId: 'layout-resource', + title: '布局图层', + mediaRef: { kind: 'project-asset', assetId: 'layout-asset' }, + x: 100, + y: 100, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 0, + groupId: null, + hidden: false, + locked: false, + flipX: false, + flipY: false, + }); + const memory = memoryHost({ initialDraft: draftFixture(scope, canvas) }); + renderSurface(memory.host); + await screen.findByLabelText('图层 布局图层'); + fireEvent.click(screen.getByRole('button', { name: 'Mock 生成' })); + expect(await screen.findByText(/明确 mock/)).toBeTruthy(); + expect(screen.getByRole('button', { name: /复位/ })).toBeTruthy(); + expect(document.querySelector('.genarrative-image-canvas__minimap')).not.toBeNull(); + const css = readFileSync( + resolve( + process.cwd(), + 'apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css', + ), + 'utf8', + ); + expect(css).toContain('height: min(800px, 100vh)'); + expect(css).toContain('@media (max-width: 760px)'); + }); + + it('相同 scope 值的父级重渲染不会重开草稿,锁定图层选择仍会持久化', async () => { + const canvas = emptyCanvas(); + canvas.layers.push({ + layerId: 'locked-layer', + resourceId: 'locked-resource', + title: '锁定图层', + mediaRef: { kind: 'project-asset', assetId: 'locked-asset' }, + x: 0, + y: 0, + width: 100, + height: 100, + originalWidth: 100, + originalHeight: 100, + zIndex: 0, + groupId: null, + hidden: false, + locked: true, + flipX: false, + flipY: false, + }); + const memory = memoryHost({ initialDraft: draftFixture(scope, canvas) }); + const onCommitted = vi.fn(); + const view = renderSurface(memory.host, { ...scope }, onCommitted); + const layer = await screen.findByLabelText('图层 锁定图层'); + expect(memory.recover).toHaveBeenCalledTimes(1); + expect(memory.loadDraft).toHaveBeenCalledTimes(1); + view.rerender( +
+ +
, + ); + await act(async () => Promise.resolve()); + expect(memory.recover).toHaveBeenCalledTimes(1); + expect(memory.loadDraft).toHaveBeenCalledTimes(1); + + fireEvent.pointerDown(layer, { pointerId: 1, clientX: 10, clientY: 10 }); + await waitFor(() => + expect(memory.updates.at(-1)?.selectedLayerIds).toEqual(['locked-layer']), + ); + }); + + it('项目切换后丢弃迟到的 mock generation 回调', async () => { + const gate = deferred(); + const oldMemory = memoryHost({ + initialDraft: draftFixture(scope), + generationGate: gate, + }); + const nextScope: ImageCanvasHostScope = { + ...scope, + projectId: 'project-generation-next', + draftId: '55555555-5555-4555-8555-555555555555', + }; + const nextMemory = memoryHost({ initialDraft: draftFixture(nextScope) }); + const onCommitted = vi.fn(); + const view = renderSurface(oldMemory.host, scope, onCommitted); + await screen.findByText('canvas.editing'); + fireEvent.click(screen.getByRole('button', { name: 'Mock 生成' })); + view.rerender( + , + ); + await screen.findByText('canvas.editing'); + await act(async () => gate.resolve()); + expect(screen.queryByText('迟到的 mock generation')).toBeNull(); + }); + + it('恢复前先建立事件订阅,并投影 recovery 发布的固定事件', async () => { + const recoveryEvent: LocalAssetCommittedEvent = { + schemaVersion: 'game-creator-local-asset-committed.v1', + eventId: 'recovery-event', + projectPath: '/moved/project', + projectId: scope.projectId, + committedProjectRevision: 1, + draftId: scope.draftId, + commitId: 'recovered-commit', + idempotencyKey: 'recovered-key', + asset: manifestFixture(scope.projectId).assets[0]!, + manifest: manifestFixture(scope.projectId), + occurredAt: 1, + }; + const memory = memoryHost({ + initialDraft: draftFixture(scope), + recoveryEvent, + }); + const { onCommitted } = renderSurface(memory.host); + await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1)); + expect(onCommitted).toHaveBeenCalledWith( + expect.objectContaining({ eventId: 'recovery-event' }), + ); + }); + + it('Tauri adapter 绑定 expectedProjectId,并在未知结果重试时复用 staging token', async () => { + const manifest = manifestFixture(scope.projectId); + let commitCalls = 0; + const invokeSpy = vi.fn(async (command: string, args?: Record) => { + if (command === 'stage_local_project_asset_canvas_image') { + return { + status: 'staged', + stagedImageToken: 'stable-staging-token', + draftId: scope.draftId, + draftRevision: 0, + draft: null, + }; + } + if (command === 'commit_local_project_asset') { + commitCalls += 1; + if (commitCalls === 1) throw new Error('IPC response lost'); + return { + status: 'already-committed', + projectId: scope.projectId, + projectRevision: 1, + committedProjectRevision: 1, + draftId: scope.draftId, + draftRevision: 1, + commitId: '77777777-7777-4777-8777-777777777777', + idempotencyKey: '88888888-8888-4888-8888-888888888888', + eventId: 'stable-event', + asset: manifest.assets[0], + manifest, + }; + } + throw new Error(`unexpected command: ${command}`); + }); + const adapter = createTauriImageCanvasHostAdapter({ + projectPath: '/fixture/project', + expectedProjectId: scope.projectId, + expectedHostRevision: 0, + invoke: invokeSpy as unknown as ( + command: string, + args?: Record, + ) => Promise, + }); + const commitInput = { + scope, + expectedHostRevision: '0', + expectedDraftRevision: 0, + commitId: '77777777-7777-4777-8777-777777777777', + idempotencyKey: '88888888-8888-4888-8888-888888888888', + name: '幂等素材', + assetKind: 'illustration', + referenceResourceIds: [], + mediaType: 'image/png' as const, + bytes: Uint8Array.from([137, 80, 78, 71]), + }; + expect((await adapter.completion.commitImage(commitInput)).status).toBe( + 'failed', + ); + const replay = await adapter.completion.commitImage(commitInput); + expect(replay.status).toBe('ok'); + expect( + invokeSpy.mock.calls.filter( + ([command]) => command === 'stage_local_project_asset_canvas_image', + ), + ).toHaveLength(1); + const commitRequests = invokeSpy.mock.calls.filter( + ([command]) => command === 'commit_local_project_asset', + ); + expect(commitRequests).toHaveLength(2); + expect(commitRequests[0]?.[1]).toEqual(commitRequests[1]?.[1]); + + const mismatch = await adapter.project.loadDraft({ + ...scope, + projectId: 'replacement-project', + }); + expect(mismatch).toEqual( + expect.objectContaining({ + status: 'failed', + code: 'project-identity-conflict', + }), + ); + expect(invokeSpy).toHaveBeenCalledTimes(3); + + const invalidRevision = await adapter.completion.commitImage({ + ...commitInput, + commitId: '99999999-9999-4999-8999-999999999999', + idempotencyKey: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + expectedHostRevision: '1e0', + }); + expect(invalidRevision).toEqual( + expect.objectContaining({ status: 'failed' }), + ); + expect(invokeSpy).toHaveBeenCalledTimes(3); + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 752724074..20c550dcd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,15 @@ # 决策记录 +## 2026-08-05 无限画布阶段三以 Tauri 持久草稿和项目 mutation 事务形成独立图片闭环 + +- 决策:阶段三新增独立 `AssetCanvasSurface` 和 Tauri Host Adapter,直接消费共享 core/React/ports;前端状态固定为 `canvas.creating / editing / saving / recovering / failed`,generation 明确使用 unsupported mock。本阶段不接资源总览入口、保存后自动选中或真实 AI,避免提前修改工作台状态合同。 +- 草稿边界:`.agent/workbench/asset-canvas/` 使用持久 OS 文件锁和 draft revision CAS,不使用 React state、localStorage 或进程内 mutex 作为正式状态。主草稿损坏时,固定 recovery JSON 必须通过针对实际字节的 SHA-256、schema/身份和全部受控媒体复核才可恢复;连续保存产生的 revision 直接从 CAS 结果推进。 +- 提交边界:正式提交复用项目 mutation 写锁,锁内按身份/revision 重检、prepared journal/ledger、最终图片安装、manifest、project revision、回读验证、ledger/草稿终态推进。`commitId/idempotencyKey` 与完整请求指纹绑定;同请求重放返回同一 asset/event,同 revision 竞争不得覆盖。释放锁后才发布至少一次 Tauri event,emit 失败不回滚已提交 manifest。 +- 恢复边界:最终文件分为 absent、matches、mismatch。只有 absent 或摘要/大小/尺寸完全匹配时可自动回滚;mismatch 保留现场并标记 `reconciliation-required`。manifest after/revision before 前向补 revision,二者 after 时补 ledger、草稿和事件。只能删除能证明属于当前事务的新文件。 +- 精修与异步边界:refine 源必须是 manifest 唯一登记且通过签名、完整解码、容量、普通文件、符号链接/硬链接门禁的图片;源 asset/文件永久保留,新 `canvas-` 通过规范 resourceId 引用源。前端以 project/draft/session epoch 丢弃迟到 Promise/事件,以 eventId 去重 command/event 投影。 +- 验证:阶段三 Surface `8/8`、共享 core/React `7/7`、AppSurface `351/351`、Rust 素材画布定向测试 `11/11`,并执行客户端 typecheck、Rust check、编码与 diff 门禁。 +- 关联:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-08-05 无限画布阶段二以共享包成为网站与 Tauri 唯一核心源码 - 决策:把现役网站画布的纯交互模型、history/viewport/stage hooks 和通用 React 容器迁到 `packages/image-canvas-core`、`packages/image-canvas-react`;原 `src/components/image-editor` 同名模型/hook 只保留 re-export。网站 Stage/World/Dock/Portal 已直接使用共享 React,Tauri 壳只建立共享源码编译入口和作用域 CSS,不在本阶段接素材创作业务闭环或修改资源总览。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fe0ce09c6..3cc03f918 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 正式保存不能等待 React state 才取得草稿 CAS 的新 revision + +- 现象:用户刚完成编辑就点击“保存到项目”,自动草稿保存已经成功,但正式提交仍携带旧 `expectedDraftRevision`,于是单窗口也得到 draft revision conflict;快速连续保存时还可能使用不同 commitId 重复 staging。 +- 原因:`setDraft(result.value)` 的 React state 提交晚于当前 Promise 链,正式保存若从闭包或下一次 render 读取 revision,会把 UI 调度时序误当成持久化顺序。相同问题也会出现在选择变化未标脏、父组件每次 render 新建 scope 对象而重复恢复、项目切换后旧 generation 回调继续写 notice。 +- 处理:草稿保存队列在 CAS 成功后同步更新 `draftRef.current` 并直接返回权威 draft;正式提交继续使用该返回值的 revision。scope 按 project/draft/intent/source 原始字段稳定化,所有导入、保存、生成和事件回调捕获当前 epoch,选择变化属于草稿合同并必须标脏。首次正式保存冻结 commitId/idempotencyKey,未知结果只重放原请求。 +- 验证:用 deferred Promise 证明草稿 CAS 完成后正式 commit 使用新 revision;相同 scope 值重渲染不重复 recover/load;锁定图层选择进入草稿更新;项目切换后迟到 generation/commit/event 均不改变新会话。 +- 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx`、`apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx`。 + ## 共享画布不能用全局 DOM 查询或宿主整包 CSS 作为隐式依赖 - 现象:页面挂两个画布时,第二个画布点击小地图会移动第一个画布;反复挂载后 wheel 触发多次。Tauri 单独引入网站 `index.css` 时还会带入账号、项目页和历史业务样式,或因仓库外源码解析到第二份 React 而出现 Hook 错误。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index dc392be5e..b400889b5 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -928,3 +928,13 @@ game-project/ - 共享样式固定为 `packages/image-canvas-react/src/styles.css`,全部以 `.genarrative-image-canvas` 作用域开头;网站可以继续用 `src/index.css` 提供站点皮肤,Tauri 只导入共享样式和自身 `styles.css`。两套 Vite 都把共享源码显式 alias 到仓库 `packages/`,Tauri dev server 保留 `fs.allow=[repoRoot]`,并通过 `resolve.dedupe=['react','react-dom']` 保证单 React 实例。 - portal 默认进入当前 Provider 指定的 root,未指定时才回退 `document.body`;事件监听、`ResizeObserver`、animation frame 和小地图查询均按组件实例清理或限定在当前 viewport。文档中只有一个小地图的旧调用形态允许唯一元素回退,页面存在多个小地图时不做全局猜测。 - 阶段二定向证据为共享 core/React 7 项与现役 `src/components/image-editor` 74 个文件、892 项测试通过;主站与 AI 游戏创作壳 typecheck 通过。构建、编码和 diff 门禁以本次任务最终验证记录为准。 + +## 2026-08-05 客户端素材创作无限画布阶段三 Tauri 独立闭环 + +- 阶段三在 `apps/ai-game-creator-shell/src/features/asset-canvas/` 增加独立 `AssetCanvasSurface` 与薄 Tauri Host Adapter,直接消费阶段二的 core/React/ports。页面实现 `canvas.creating / editing / saving / recovering / failed`,支持 PNG/JPEG/WebP 本地导入、选择、多选、移动、缩放、删除、60 步撤销重做、viewport 平移缩放、小地图、复位和浏览器合成导出;generation port 明确返回 mock unsupported,不发送真实 AI 请求。 +- 草稿正式保存到 `.agent/workbench/asset-canvas/`,由 Tauri Rust 使用持久 OS 文件锁和 `expectedDraftRevision` CAS 串行化;主草稿、固定 recovery JSON 与实际 recovery 字节的裸 SHA-256 一起安装。主文件损坏时只有 schema、项目/草稿身份、摘要及全部媒体签名、摘要、尺寸和普通文件身份都可信才恢复,否则进入 `reconciliation-required`。单文件限制 `64 MiB`,单草稿媒体总量限制 `512 MiB`。 +- 正式提交先把合成结果安全写入受控 staging,再进入既有项目 mutation 写锁。锁内重读并校验 manifest、项目身份、项目 revision、草稿 revision 和提交指纹,写 prepared journal/ledger 与 before/after 快照,安全安装唯一最终图片,原子更新 manifest 和 project revision,回读图片/manifest/revision 后提交 ledger 与草稿;释放锁后才发布 `game-creator-local-asset-committed`。同 `commitId/idempotencyKey` 同请求返回原权威结果,同键不同请求失败关闭,同 expected revision 竞争最多一个成功。 +- 崩溃恢复只按 journal、ledger、before/after 摘要和最终文件三态推进:图片与 manifest/revision 都在 before 时,只清理摘要和尺寸可证明属于本事务的新文件;最终路径不存在可直接回滚;最终路径存在但身份不匹配必须保留文件并进入对账。manifest 已安装而 revision 未安装时前向补 revision;manifest/revision 都是 after 时补齐 ledger、草稿和事件。业务事实完成后 emit 失败不回滚,后续恢复继续按固定 eventId 发布。 +- refine 只允许从当前 manifest 唯一登记的 PNG/JPEG/WebP 普通文件建立草稿,并复核签名、完整解码、大小、符号链接和硬链接。正式保存保留源 asset 与源文件,为缺失 resourceId 的源补 `local-asset:`,新建 `canvas-` 并以 `referenceResourceIds` 引用源资源。 +- 页面以 project/draft/session epoch 丢弃项目切换、会话切换和卸载后的旧 Promise/事件,并以 eventId 去重 command 返回与至少一次事件。相同 scope 字段的父级重渲染不会重复打开草稿,选择变化也进入草稿 CAS;正式保存直接消费 CAS 返回的新 draft revision,不等待 React state 提交时序。 +- 本阶段只提供独立 Surface/fixture 与 Tauri commands,不接 `project-development` 资源总览按钮、不做保存后的资源总览自动选中,也不接真实 External Editor API。定向证据为阶段三 Surface `8/8`、共享 core/React `7/7`、既有 AppSurface `351/351`、Rust 素材画布 `11/11` 与客户端 typecheck 通过;AppSurface 保留仓库既有 React `act(...)` 警告,不影响测试结果。 diff --git a/packages/image-canvas-core/src/ports.ts b/packages/image-canvas-core/src/ports.ts index 7c6502382..26e9ccdf5 100644 --- a/packages/image-canvas-core/src/ports.ts +++ b/packages/image-canvas-core/src/ports.ts @@ -33,23 +33,88 @@ export type ImageCanvasHostResult = export type ImageCanvasMediaRef = | { kind: 'project-asset'; assetId: string } + | { + kind: 'draft-media'; + mediaId: string; + mediaType: 'image/png' | 'image/jpeg' | 'image/webp'; + sha256: string; + byteLength: number; + pixelWidth: number; + pixelHeight: number; + } | { kind: 'host-resource'; resourceId: string }; +export type ImageCanvasDraftLayer = { + layerId: string; + resourceId: string; + title: string; + mediaRef: ImageCanvasMediaRef; + x: number; + y: number; + width: number; + height: number; + originalWidth: number; + originalHeight: number; + zIndex: number; + groupId: string | null; + hidden: boolean; + locked: boolean; + flipX: boolean; + flipY: boolean; +}; + +export type ImageCanvasDraftCanvas = { + viewport: { x: number; y: number; scale: number }; + backgroundColor: string; + layers: ImageCanvasDraftLayer[]; + selectedLayerIds: string[]; + primarySelectedLayerId: string | null; +}; + export type ImageCanvasDraft = { + schemaVersion: 'game-creator-asset-canvas-draft.v1'; draftId: string; projectId: string; + intent: 'create' | 'refine'; + sourceAssetId: string | null; + sourceResourceId: string | null; revision: number; - hostRevision: string; - canvas: unknown; + status: + | 'editing' + | 'generating' + | 'commit-prepared' + | 'committed' + | 'cancelled' + | 'reconciliation-required'; + canvas: ImageCanvasDraftCanvas; generations: ImageCanvasGenerationRecord[]; + pendingCommit: { + commitId: string; + idempotencyKey: string; + requestFingerprint: string; + } | null; + lastCommit: { + commitId: string; + idempotencyKey: string; + assetId: string; + eventId: string; + committedProjectRevision: number; + } | null; + createdAt: number; + updatedAt: number; }; export type ImageCanvasGenerationRecord = { generationId: string; idempotencyKey: string; - status: 'pending' | 'running' | 'completed' | 'failed'; - resourceIds: string[]; - errorCode?: string | null; + status: 'accepted' | 'polling' | 'completed' | 'failed'; + prompt: string; + operationId: string | null; + referenceResourceIds: string[]; + outputMediaIds: string[]; + errorCode: string | null; + createdAt: number; + updatedAt: number; }; export type ImageCanvasHostInputImage = { @@ -149,6 +214,9 @@ export interface ImageCanvasCompletionPort { resourceId: string; draftRevision: number; hostRevision: string; + commitStatus?: 'committed' | 'already-committed'; + manifest?: unknown; + eventId?: string; }> >; } diff --git a/vitest.config.ts b/vitest.config.ts index 562bff31f..91378d9cb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -84,6 +84,7 @@ export default defineConfig({ 'apps/admin-web/src/**/*.test.ts', 'apps/admin-web/src/**/*.test.tsx', 'apps/ai-game-creator-shell/tests/**/*.test.ts', + 'apps/ai-game-creator-shell/tests/**/*.test.tsx', 'miniprogram/**/*.test.js', 'scripts/**/*.test.ts', 'packages/shared/src/contracts/hostBridge.test.ts',