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 572c64abd..fba330d73 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1019,6 +1019,42 @@ pub(crate) fn delete_local_project_asset( ) } +/// 读取某个版本引用的源素材可用的替换候选,并给出后端权威的三项兼容性结论。 +/// +/// 只读:不改 manifest、不推进 revision。候选渲染但禁用,不在前端重算判据。 +#[tauri::command] +pub(crate) fn read_local_project_version_resource_replacement_candidates( + input: ReadLocalProjectVersionReplacementCandidatesInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + read_local_project_version_replacement_candidates_at( + root, + &input.source_version_id, + &input.source_resource_id, + ) +} + +/// 用另一个已登记素材替换某个版本引用的素材:改 manifest 绑定,并**追加下一迭代版本**。 +/// +/// 可运行版本不可变(既有版本记录一个字节都不改)、不动资源文件、不建文件副本; +/// 三项兼容性必须同时为 true,否则拒绝并说明哪一项不等。CAS 失败时 manifest 与 revision 都不变。 +#[tauri::command] +pub(crate) fn replace_local_project_version_resource( + input: ReplaceLocalProjectVersionResourceInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + replace_local_project_version_resource_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.source_version_id, + &input.source_resource_id, + &input.replacement_resource_id, + ) +} + /// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。 /// /// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败 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 94e835a7c..0df79b918 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2698,6 +2698,8 @@ fn main() { delete_local_project_asset, read_local_project_asset_references, rename_local_project_asset, + read_local_project_version_resource_replacement_candidates, + replace_local_project_version_resource, get_local_game_project_revision, get_local_game_manifest, download_agc_update, 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 b91cb3959..3cd6e4ac2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -17,6 +17,7 @@ mod resource_dependency_graph; mod resource_editor; mod resource_layout; mod verification; +mod version_resource_replacement; mod write_lock; pub(crate) use agent_db::*; @@ -33,4 +34,5 @@ pub(crate) use resource_dependency_graph::*; pub(crate) use resource_editor::*; pub(crate) use resource_layout::*; pub(crate) use verification::*; +pub(crate) use version_resource_replacement::*; pub(crate) use write_lock::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index bc72a7fad..39ed157a6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1391,6 +1391,91 @@ fn no_version_removals(_manifest: &GameCreationAppManifest) -> Vec { Vec::new() } +/// 同 [`mutate_manifest_at`],但允许显式放行的既有版本**只改写自己的 `resourceBindings`**。 +/// +/// 这是"直接替换资源"的写入通道(PRD §3.2 的直接替换口径):把某个版本的绑定从素材 A 指向 +/// 素材 B,既不新增版本、也不删除版本。放行面被刻意压到最小: +/// +/// - **不增不删**:候选与磁盘的版本数量必须相等; +/// - **不重排**:版本 ID 序列必须逐项相同; +/// - **只有放行 ID 允许 `resourceBindings` 不同**,其余字段(`versionId` / `parentVersionId` / +/// `projectRevision` / `createdReason` / `createdAt` / `editPrompt`)逐字段相等; +/// - 未放行的版本必须整条相等; +/// - `allowed_binding_rewrites` 按**写入前的 manifest**求值(与删除放行口同口径); +/// - 与删除放行集合**互斥**:同一版本 ID 不得既被放行删除、又被放行改写绑定。 +/// +/// 既有 [`mutate_manifest_at_allowing_version_removals`] 与 +/// [`validate_version_records_are_append_only`] 的语义不受影响:本通道是**另一条**独立、更窄的 +/// 校验路径,默认路径仍然只有"只追加 + 显式放行删除"。 +pub(crate) fn mutate_manifest_allowing_version_binding_rewrites( + root: &Path, + allowed_binding_rewrites: &dyn Fn(&GameCreationAppManifest) -> Vec, + mutate: impl FnOnce(&mut GameCreationAppManifest) -> Result, +) -> Result { + if root.as_os_str().is_empty() { + return Err("项目目录不能为空".to_string()); + } + if !root.is_absolute() { + return Err("项目目录必须是绝对路径".to_string()); + } + let manifest_path = root.join(".agent/manifest.json"); + if let Some(parent) = manifest_path.parent() { + ensure_game_creator_private_directory_tree(parent, "manifest 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "manifest 目录")?; + } + let _write_lock = acquire_manifest_write_lock(&manifest_path)?; + let (_, mut manifest) = read_or_create_manifest(root)?; + let allowed_binding_rewrites = allowed_binding_rewrites(&manifest); + let result = mutate(&mut manifest)?; + write_manifest_locked_with_version_guard(&manifest_path, &manifest, &|existing, candidate| { + validate_version_records_allow_binding_rewrites( + &existing.versions, + &candidate.versions, + &allowed_binding_rewrites, + &[], + ) + })?; + Ok(result) +} + +/// 项目版本数组的**第二条**放行通道:只放行"显式列出的版本改写自己的 `resourceBindings`"。 +/// +/// 与 [`validate_version_records_are_append_only`] 并列存在、互不替代:本函数不做任何"允许追加/ +/// 允许删除/允许重排"的放宽,只在一个字段上开一个显式白名单窗口。 +fn validate_version_records_allow_binding_rewrites( + existing: &[GameIterationVersion], + candidate: &[GameIterationVersion], + allowed_binding_rewrites: &[String], + allowed_version_removals: &[String], +) -> Result<(), String> { + let violation = || "项目版本记录写入后不可修改、删除或重排".to_string(); + // 两条放行通道必须互斥:既被放行删除、又被放行改写绑定,等于同时表达"这条消失"与 + // "这条保留但改绑定",语义自相矛盾,直接失败关闭。 + if allowed_binding_rewrites + .iter() + .any(|version_id| allowed_version_removals.contains(version_id)) + { + return Err("版本绑定改写放行与版本删除放行必须互斥".to_string()); + } + // 直接替换不产生新版本,也不允许任何版本消失。 + if existing.len() != candidate.len() { + return Err(violation()); + } + for (existing_version, candidate_version) in existing.iter().zip(candidate.iter()) { + if existing_version.version_id != candidate_version.version_id { + return Err(violation()); + } + let mut normalized = candidate_version.clone(); + if allowed_binding_rewrites.contains(&existing_version.version_id) { + normalized.resource_bindings = existing_version.resource_bindings.clone(); + } + if normalized != *existing_version { + return Err(violation()); + } + } + Ok(()) +} + /// 项目版本数组的写入边界:既有版本只允许按显式放行的 ID 删除,其余必须原样保留且只能追加。 fn validate_version_records_are_append_only( existing: &[GameIterationVersion], @@ -1767,6 +1852,28 @@ fn write_manifest_locked( path: &Path, manifest: &GameCreationAppManifest, allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec, +) -> Result<(), String> { + write_manifest_locked_with_version_guard(path, manifest, &|existing, candidate| { + validate_version_records_are_append_only( + &existing.versions, + &candidate.versions, + &allowed_version_removals(existing), + ) + }) +} + +/// 版本数组写入的公共体:把"装盘 / 回读 / 原子替换"这套存储细节与版本校验分开。 +/// +/// 校验器由调用方注入,所以 [`write_manifest_locked`](只追加 + 显式放行删除)与 +/// [`mutate_manifest_allowing_version_binding_rewrites`](只放行显式版本的绑定改写)共用同一份 +/// 装盘实现,不会出现两份略有差异的原子替换路径。存储行为与改造前逐行等价。 +fn write_manifest_locked_with_version_guard( + path: &Path, + manifest: &GameCreationAppManifest, + version_guard: &dyn Fn( + &GameCreationAppManifest, + &GameCreationAppManifest, + ) -> Result<(), String>, ) -> Result<(), String> { validate_manifest_schema_version(&manifest.schema_version) .map_err(|error| format!("校验 manifest schema 版本失败:{error}"))?; @@ -1776,11 +1883,7 @@ fn write_manifest_locked( .map_err(|error| format!("序列化 manifest 失败:{error}"))?; if manifest_storage_exists(path)? { let existing = read_manifest(path)?; - validate_version_records_are_append_only( - &existing.versions, - &manifest.versions, - &allowed_version_removals(&existing), - )?; + version_guard(&existing, manifest)?; } match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { @@ -1863,6 +1966,8 @@ mod classification_tests; mod import_tests; #[cfg(test)] mod recovery_tests; +#[cfg(test)] +mod version_binding_rewrite_tests; #[cfg(test)] mod npm_scaffold_tests { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs new file mode 100644 index 000000000..3ebde4c55 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs @@ -0,0 +1,343 @@ +use super::*; +use shared_contracts::game_creation_app::{ + GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding, +}; + +/// 「直接替换」的写入通道用例:只放行显式版本的 `resourceBindings` 改写,其余一切照旧失败关闭。 +/// +/// 这些用例同时是"只追加保证没有被这条新通道打穿"的证据:不增、不删、不重排、不改别的字段。 + +fn unique_binding_rewrite_root(test_name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-binding-rewrite-{test_name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) +} + +fn binding(resource_id: &str) -> GameIterationVersionResourceBinding { + GameIterationVersionResourceBinding { + slot_id: format!("asset:{resource_id}"), + resource_id: resource_id.to_string(), + } +} + +fn binding_rewrite_version( + version_id: &str, + parent_version_id: Option<&str>, + project_revision: u64, + created_reason: GameIterationVersionCreatedReason, + resource_ids: &[&str], +) -> GameIterationVersion { + GameIterationVersion { + version_id: version_id.to_string(), + parent_version_id: parent_version_id.map(str::to_string), + project_revision, + resource_bindings: resource_ids.iter().copied().map(binding).collect(), + created_reason, + created_at: project_revision, + edit_prompt: None, + } +} + +/// 三版本项目:root → child → grandchild,绑定集合各不同,供"只放行一条"的对照使用。 +fn binding_rewrite_fixture(test_name: &str) -> (PathBuf, PathBuf, GameCreationAppManifest) { + let root = unique_binding_rewrite_root(test_name); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = new_game_creation_app_manifest("project-binding-rewrite", "绑定改写项目"); + manifest.versions = vec![ + binding_rewrite_version( + "version-root", + None, + 1, + GameIterationVersionCreatedReason::Initial, + &["asset-a"], + ), + binding_rewrite_version( + "version-child", + Some("version-root"), + 2, + GameIterationVersionCreatedReason::AgentRevision, + &["asset-a", "asset-b"], + ), + binding_rewrite_version( + "version-grandchild", + Some("version-child"), + 3, + GameIterationVersionCreatedReason::AgentRevision, + &["asset-a", "asset-b", "asset-c"], + ), + ]; + write_manifest(&manifest_path, &manifest).expect("write versioned manifest fixture"); + (root, manifest_path, manifest) +} + +/// 放行的版本:只改自己的 `resourceBindings` 成功,其余版本与其余字段逐字不动。 +#[test] +fn binding_rewrite_allows_only_the_whitelisted_version_bindings() { + let (root, manifest_path, installed) = binding_rewrite_fixture("allowed"); + let version_id = "version-child".to_string(); + + mutate_manifest_allowing_version_binding_rewrites( + &root, + &|_manifest| vec![version_id.clone()], + |manifest| { + let version = manifest + .versions + .iter_mut() + .find(|version| version.version_id == version_id) + .expect("child version exists"); + version.resource_bindings = vec![binding("asset-a"), binding("asset-b2")]; + Ok(()) + }, + ) + .expect("放行版本改写自己的绑定必须成功"); + + let written = read_manifest(&manifest_path).expect("read rewritten manifest"); + assert_eq!(written.versions.len(), installed.versions.len(), "不得增删版本"); + assert_eq!( + written.versions[0], installed.versions[0], + "未放行版本必须整条相等" + ); + assert_eq!( + written.versions[2], installed.versions[2], + "未放行版本必须整条相等" + ); + // 放行版本:除绑定外逐字段相等。 + let rewritten = &written.versions[1]; + let original = &installed.versions[1]; + assert_eq!(rewritten.version_id, original.version_id); + assert_eq!(rewritten.parent_version_id, original.parent_version_id); + assert_eq!(rewritten.project_revision, original.project_revision); + assert_eq!(rewritten.created_reason, original.created_reason); + assert_eq!(rewritten.created_at, original.created_at); + assert_eq!(rewritten.edit_prompt, original.edit_prompt); + assert_eq!( + rewritten.resource_bindings, + vec![binding("asset-a"), binding("asset-b2")] + ); + + fs::remove_dir_all(root).ok(); +} + +/// 未放行的版本改绑定 → 拒绝,manifest 字节不变。 +#[test] +fn binding_rewrite_rejects_versions_outside_the_whitelist() { + let (root, manifest_path, _installed) = binding_rewrite_fixture("outside-whitelist"); + let before = fs::read(&manifest_path).expect("read manifest bytes before rejection"); + + let error = mutate_manifest_allowing_version_binding_rewrites( + &root, + &|_manifest| vec!["version-child".to_string()], + |manifest| { + // 放行的是 child,改的却是 root。 + manifest.versions[0].resource_bindings = vec![binding("asset-z")]; + Ok(()) + }, + ) + .expect_err("未放行版本改绑定必须被拒绝"); + assert!(error.contains("不可修改、删除或重排"), "{error}"); + assert_eq!( + fs::read(&manifest_path).expect("read manifest bytes after rejection"), + before, + "被拒绝的写入不得改动 manifest" + ); + + fs::remove_dir_all(root).ok(); +} + +/// 不增不删:追加新版本、删除版本都被这条通道拒绝(它不放行任何增删)。 +#[test] +fn binding_rewrite_rejects_version_additions_and_removals() { + let (root, manifest_path, _installed) = binding_rewrite_fixture("no-add-no-remove"); + let before = fs::read(&manifest_path).expect("read manifest bytes before rejection"); + let version_id = "version-child".to_string(); + + let append_error = mutate_manifest_allowing_version_binding_rewrites( + &root, + &|_manifest| vec![version_id.clone()], + |manifest| { + manifest.versions.push(binding_rewrite_version( + "version-appended", + Some("version-grandchild"), + 4, + GameIterationVersionCreatedReason::AgentRevision, + &["asset-d"], + )); + Ok(()) + }, + ) + .expect_err("绑定改写通道不得追加版本"); + assert!(append_error.contains("不可修改、删除或重排"), "{append_error}"); + + let remove_error = mutate_manifest_allowing_version_binding_rewrites( + &root, + &|_manifest| vec![version_id.clone()], + |manifest| { + manifest.versions.remove(2); + Ok(()) + }, + ) + .expect_err("绑定改写通道不得删除版本"); + assert!(remove_error.contains("不可修改、删除或重排"), "{remove_error}"); + + assert_eq!( + fs::read(&manifest_path).expect("read manifest bytes after rejections"), + before + ); + + fs::remove_dir_all(root).ok(); +} + +/// 不重排:版本 ID 序列一变就必须拒绝。 +/// +/// 集成路径上"换序"会先被版本图校验拦下(父版本必须先于子版本存在),所以这里两条都测: +/// ① 直接打校验器,精确命中"不重排"分支;② 走真实通道,确认任何换序都失败关闭且字节不变。 +#[test] +fn binding_rewrite_rejects_version_reordering() { + let (root, manifest_path, installed) = binding_rewrite_fixture("no-reorder"); + let before = fs::read(&manifest_path).expect("read manifest bytes before rejection"); + + let mut reordered = installed.versions.clone(); + reordered.swap(1, 2); + let direct_error = validate_version_records_allow_binding_rewrites( + &installed.versions, + &reordered, + &[], + &[], + ) + .expect_err("版本 ID 序列变化必须被拒绝"); + assert!(direct_error.contains("不可修改、删除或重排"), "{direct_error}"); + + let channel_error = mutate_manifest_allowing_version_binding_rewrites( + &root, + &|_manifest| vec!["version-child".to_string()], + |manifest| { + manifest.versions.swap(1, 2); + Ok(()) + }, + ) + .expect_err("绑定改写通道不得重排版本"); + assert!( + channel_error.contains("项目版本"), + "拒绝原因必须仍然来自版本校验:{channel_error}" + ); + assert_eq!( + fs::read(&manifest_path).expect("read manifest bytes after rejection"), + before + ); + + fs::remove_dir_all(root).ok(); +} + +/// 放行版本改**其它字段**同样拒绝(每一条都先通过版本图校验,才能精确命中改写守卫)。 +#[test] +fn binding_rewrite_rejects_other_field_changes_on_the_whitelisted_version() { + let (root, manifest_path, _installed) = binding_rewrite_fixture("other-fields"); + let before = fs::read(&manifest_path).expect("read manifest bytes before rejection"); + let version_id = "version-child".to_string(); + + let allowed = |_: &GameCreationAppManifest| vec![version_id.clone()]; + let cases: [(&str, fn(&mut GameCreationAppManifest)); 4] = [ + ("createdAt", |manifest| { + manifest.versions[1].created_at = 1; + }), + ("editPrompt", |manifest| { + manifest.versions[1].edit_prompt = Some("重新出图".to_string()); + }), + ("parentVersionId", |manifest| { + manifest.versions[2].parent_version_id = Some("version-root".to_string()); + }), + ("versionId", |manifest| { + manifest.versions[2].version_id = "version-renamed".to_string(); + }), + ]; + + for (label, mutate) in cases { + let error = mutate_manifest_allowing_version_binding_rewrites(&root, &allowed, |manifest| { + mutate(manifest); + Ok(()) + }) + .err() + .unwrap_or_else(|| panic!("{label} 必须被拒绝")); + assert!( + error.contains("不可修改、删除或重排"), + "{label} 的拒绝原因必须来自版本改写守卫:{error}" + ); + assert_eq!( + fs::read(&manifest_path).expect("read manifest bytes after rejection"), + before, + "{label} 被拒绝后字节不得变化" + ); + } + + fs::remove_dir_all(root).ok(); +} + +/// 两组放行集合必须互斥:同一版本 ID 既被放行删除、又被放行改写绑定 → 失败关闭。 +#[test] +fn binding_rewrite_whitelist_must_be_disjoint_from_version_removals() { + let (root, manifest_path, installed) = binding_rewrite_fixture("disjoint-whitelists"); + let before = fs::read(&manifest_path).expect("read manifest bytes before rejection"); + + let error = validate_version_records_allow_binding_rewrites( + &installed.versions, + &installed.versions, + &["version-child".to_string()], + &["version-child".to_string()], + ) + .expect_err("两组放行集合重叠必须被拒绝"); + assert!(error.contains("必须互斥"), "{error}"); + + // 真实写入路径也拿不到重叠放行:该通道固定传空删除集合。 + assert_eq!( + fs::read(&manifest_path).expect("read manifest bytes after rejection"), + before + ); + + fs::remove_dir_all(root).ok(); +} + +/// 只追加保证没有被打穿:默认路径与删除放行通道仍然拒绝绑定改写。 +#[test] +fn binding_rewrite_channel_does_not_loosen_the_default_or_removal_paths() { + let (root, manifest_path, installed) = binding_rewrite_fixture("default-path-unchanged"); + let before = fs::read(&manifest_path).expect("read manifest bytes before rejection"); + + let default_error = mutate_manifest_at(&root, |manifest| { + manifest.versions[1].resource_bindings = vec![binding("asset-b2")]; + Ok(()) + }) + .expect_err("默认写入路径必须继续拒绝绑定改写"); + assert!( + default_error.contains("不可修改、删除或重排"), + "{default_error}" + ); + + let removal_channel_error = mutate_manifest_at_allowing_version_removals( + &root, + &|_manifest| vec!["version-child".to_string()], + |manifest| { + // 即使用删除放行口把这条列进去,它也不能"保留下来顺便改绑定"。 + manifest.versions[1].resource_bindings = vec![binding("asset-b2")]; + Ok(()) + }, + ) + .expect_err("删除放行通道不得用于绑定改写"); + assert!( + removal_channel_error.contains("不可修改、删除或重排"), + "{removal_channel_error}" + ); + + assert_eq!( + fs::read(&manifest_path).expect("read manifest bytes after rejections"), + before + ); + let unchanged = read_manifest(&manifest_path).expect("read unchanged manifest"); + assert_eq!(unchanged.versions.len(), installed.versions.len()); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs new file mode 100644 index 000000000..27fc331af --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs @@ -0,0 +1,484 @@ +use super::*; + +use shared_contracts::game_creation_app::{ + canonical_game_creation_app_asset_kind, game_creation_app_asset_effective_category, + GameCreationAppAssetManifestEntry, +}; + +/// 版本级资源替换(**直接替换**口径)。 +/// +/// 落盘语义:**改 manifest 里该版本的绑定,指向另一个已登记资源** —— +/// 不追加新版本、不删除版本、不动资源文件、不建文件副本。 +/// +/// 写入通道是 `project/manifest.rs` 的 `mutate_manifest_allowing_version_binding_rewrites`: +/// 它只放行"显式列出的版本改写自己的 `resourceBindings`",不增删、不重排、不改其它字段。 +/// 因此本命令既表达了"这个版本现在用另一个素材",又没有把版本记录的只追加保证打穿。 +/// +/// 恒等绑定口径(`slotId` 恒为 `asset:{resourceId}`)下的绑定改写 = **源素材从这个版本的绑定 +/// 集合里消失 + 替换素材出现在这个集合里**: +/// - 替换素材是源版本创建之后才登记时,按源素材原来的位置插回,保持顺序稳定; +/// - 替换素材早就登记(因此早就在集合里)时只做摘除 —— 不能"把源素材那条槽位改写成替换素材", +/// 那会撞「资源槽位重复」。 +/// +/// 准入与提示: +/// - **硬门禁**只有 `categoryEqual` 与 `subtypeEqual`(拦"把角色图换成背景音乐"这类明显错误); +/// - `sizeSpecEqual` 只作为**提示**、不拒绝:它的完整判据今天不存在(manifest 资产表没有 +/// `width / height / durationMs`,实际只等于"媒体格式相等"),硬拦会误拒 `png ↔ webp` 这种 +/// 直接替换里最常见的正常需求。 +/// +/// 已知代价:直接替换形态下**没有可回溯的替换历史**。替换前的绑定身份只存在于 +/// ① `asset.version_binding.replace` 审计记录 ② manifest 的 `.previous` 恢复副本。 +/// 需要"某个版本历史上被换过几次、换成过什么"时要另立切片(例如 PRD §3.2 的版本级替换)。 +const VERSION_REPLACEMENT_MAX_ID_CHARS: usize = 512; + +/// 直接替换的审计记录类型,与 `asset.register` / `asset.update` 同族。 +const VERSION_BINDING_REPLACE_RECORD_TYPE: &str = "asset.version_binding.replace"; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ReadLocalProjectVersionReplacementCandidatesInput { + pub(crate) project_path: String, + pub(crate) source_version_id: String, + pub(crate) source_resource_id: String, +} + +/// 三项兼容性。只有前两项是硬门禁;`size_spec_equal` 仅用于提示。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectVersionResourceCompatibility { + pub(crate) category_equal: bool, + pub(crate) subtype_equal: bool, + pub(crate) size_spec_equal: bool, +} + +impl ProjectVersionResourceCompatibility { + /// 硬门禁:分类与类型。任一不等即拒绝替换。 + fn blocking_reason(self) -> Option<&'static str> { + if !self.category_equal { + Some("分类不同") + } else if !self.subtype_equal { + Some("类型不同") + } else { + None + } + } + + /// 提示:通过硬门禁、但尺寸规格判据不等时给出(不拒绝)。 + fn warning(self) -> Option<&'static str> { + if self.blocking_reason().is_none() && !self.size_spec_equal { + Some("格式与源素材不同") + } else { + None + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectVersionReplacementCandidate { + pub(crate) resource_id: String, + /// 能否选中:等价于"没有硬门禁原因"。 + pub(crate) compatible: bool, + pub(crate) compatibility: ProjectVersionResourceCompatibility, + /// 硬门禁原因;通过时为 `null`。候选**渲染但禁用**,不用隐藏伪装成"素材不存在"。 + pub(crate) blocked_reason: Option<&'static str>, + /// 非阻断提示(当前只有"格式与源素材不同")。 + pub(crate) warning: Option<&'static str>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReadLocalProjectVersionReplacementCandidatesResult { + pub(crate) source_version_id: String, + pub(crate) source_resource_id: String, + pub(crate) candidates: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ReplaceLocalProjectVersionResourceInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_project_revision: u64, + pub(crate) source_version_id: String, + pub(crate) source_resource_id: String, + pub(crate) replacement_resource_id: String, +} + +/// 这一次替换的记录:哪个版本的哪个绑定指向改成了哪个素材。 +/// +/// 直接替换不产生新版本,所以没有 `parentVersionId`、也没有可推的前后版本对。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectVersionResourceReplacement { + pub(crate) version_id: String, + pub(crate) source_resource_id: String, + pub(crate) replacement_resource_id: String, + pub(crate) compatibility: ProjectVersionResourceCompatibility, + pub(crate) warning: Option<&'static str>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReplaceLocalProjectVersionResourceResult { + pub(crate) version_id: String, + pub(crate) committed_project_revision: u64, + pub(crate) replacement: ProjectVersionResourceReplacement, +} + +/// 槽位 ID 恒等于 `asset:{resourceId}`(v3 恒等绑定口径),绑定改写时同步维护。 +fn version_binding_slot_id(resource_id: &str) -> String { + format!("asset:{resource_id}") +} + +/// 资源媒体格式身份。 +/// +/// 只做「声明值归一 + 缺声明时按扩展名回退」:去参数段(`; charset=...`)、去首尾空白、 +/// 转小写,并把 `image/jpg` 归到 canonical 的 `image/jpeg`(与 `assets.rs` 的 +/// `CanvasImageFormat::from_media_type` 同口径)。**不**做全量 MIME 规范化表。 +fn canonical_asset_media_format(asset: &GameCreationAppAssetManifestEntry) -> String { + let declared = asset + .media_type + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if !declared.is_empty() { + return if declared == "image/jpg" { + "image/jpeg".to_string() + } else { + declared + }; + } + Path::new(&asset.local_path) + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + .trim() + .to_ascii_lowercase() +} + +/// 已知尺寸事实:`imageSequenceFrames` 首帧的像素尺寸。没有帧事实时为 `None`。 +fn asset_known_frame_size(asset: &GameCreationAppAssetManifestEntry) -> Option<(u32, u32)> { + asset + .image_sequence_frames + .as_ref() + .and_then(|frames| frames.first()) + .map(|frame| (frame.width, frame.height)) +} + +/// 已知时长事实:仅序列资产有;其余类型为 `None`。 +fn asset_known_duration_ms(asset: &GameCreationAppAssetManifestEntry) -> Option { + asset.image_sequence_duration_ms +} + +/// `sizeSpecEqual` 判据(**只提示、不拒绝**)。 +/// +/// 判据 = 规范化媒体格式相等 **且**「任一方有事实的维度必须相等」(双方都无事实的维度不参与)。 +/// +/// **诚实标注(刻意接受的降级)**:manifest 资产表没有 `width / height / durationMs` 字段, +/// 且现役写入侧(上传、派生、画板回传、生成回流)几乎全部写 `imageSequenceFrames: None`, +/// 所以这条判据在真实数据上**退化为"媒体格式相等"**。正因为它不完整,本轮**不用它拒绝**替换, +/// 只在弹窗里提示"格式与源素材不同";要变成硬判据,必须先给 manifest asset 增尺寸字段并在写入侧 +/// 回填(跨端契约变更)。 +fn version_resource_size_spec_equal( + source: &GameCreationAppAssetManifestEntry, + replacement: &GameCreationAppAssetManifestEntry, +) -> bool { + let format_equal = + canonical_asset_media_format(source) == canonical_asset_media_format(replacement); + let size_equal = match ( + asset_known_frame_size(source), + asset_known_frame_size(replacement), + ) { + (None, None) => true, + (source_size, replacement_size) => source_size == replacement_size, + }; + let duration_equal = match ( + asset_known_duration_ms(source), + asset_known_duration_ms(replacement), + ) { + (None, None) => true, + (source_duration, replacement_duration) => source_duration == replacement_duration, + }; + format_equal && size_equal && duration_equal +} + +/// 三项兼容性判据(后端是权威判据,前端只做呈现)。 +/// +/// - `categoryEqual`:功能分类相等,用**读时自愈**口径(PRD §5.3「分类取值优先级」收口); +/// - `subtypeEqual`:canonical `kind` 相等(别名表在 `shared-contracts`)——以上两项是硬门禁; +/// - `sizeSpecEqual`:见 [`version_resource_size_spec_equal`],只作提示。 +fn version_resource_compatibility( + source: &GameCreationAppAssetManifestEntry, + replacement: &GameCreationAppAssetManifestEntry, +) -> ProjectVersionResourceCompatibility { + ProjectVersionResourceCompatibility { + category_equal: game_creation_app_asset_effective_category( + &source.kind, + source.category, + ) == game_creation_app_asset_effective_category( + &replacement.kind, + replacement.category, + ), + subtype_equal: canonical_game_creation_app_asset_kind(&source.kind) + == canonical_game_creation_app_asset_kind(&replacement.kind), + size_spec_equal: version_resource_size_spec_equal(source, replacement), + } +} + +fn normalized_version_id<'a>(value: &'a str) -> Result<&'a str, String> { + let value = value.trim(); + if value.is_empty() { + return Err("sourceVersionId 不能为空".to_string()); + } + if value.chars().count() > VERSION_REPLACEMENT_MAX_ID_CHARS { + return Err("sourceVersionId 过长".to_string()); + } + Ok(value) +} + +fn normalized_resource_id<'a>(value: &'a str, label: &str) -> Result<&'a str, String> { + let value = value.trim(); + if value.is_empty() { + return Err(format!("{label} 不能为空")); + } + if value.chars().count() > VERSION_REPLACEMENT_MAX_ID_CHARS { + return Err(format!("{label} 过长")); + } + Ok(value) +} + +fn replacement_source_version<'a>( + manifest: &'a GameCreationAppManifest, + source_version_id: &str, +) -> Result<&'a GameIterationVersion, String> { + manifest + .versions + .iter() + .find(|version| version.version_id == source_version_id) + .ok_or_else(|| format!("源项目版本不存在:{source_version_id}")) +} + +/// 源版本必须真的绑定着源素材(恒等绑定:`slotId` 与 `resourceId` 同时命中)。 +fn require_source_binding( + version: &GameIterationVersion, + source_resource_id: &str, +) -> Result<(), String> { + let slot_id = version_binding_slot_id(source_resource_id); + if version + .resource_bindings + .iter() + .any(|binding| binding.slot_id == slot_id && binding.resource_id == source_resource_id) + { + return Ok(()); + } + Err(format!( + "源版本未绑定该素材:{} · {}", + version.version_id, source_resource_id + )) +} + +fn manifest_asset<'a>( + manifest: &'a GameCreationAppManifest, + resource_id: &str, +) -> Result<&'a GameCreationAppAssetManifestEntry, String> { + manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| format!("项目资源不存在:{resource_id}")) +} + +/// 读取源素材在当前 manifest 里可用的替换候选,并给出后端权威的兼容性结论与提示。 +/// +/// 只读:不改 manifest、不推进 revision。候选按 `manifest.assets` 顺序返回,源素材自身排除。 +pub(crate) fn read_local_project_version_replacement_candidates_at( + root: &Path, + source_version_id: &str, + source_resource_id: &str, +) -> Result { + let source_version_id = normalized_version_id(source_version_id)?; + let source_resource_id = normalized_resource_id(source_resource_id, "sourceResourceId")?; + let manifest = read_existing_manifest_for_project(root)?; + let source_version = replacement_source_version(&manifest, source_version_id)?; + require_source_binding(source_version, source_resource_id)?; + let source_asset = manifest_asset(&manifest, source_resource_id)?; + + let candidates = manifest + .assets + .iter() + .filter(|asset| asset.id != source_resource_id) + .map(|asset| { + let compatibility = version_resource_compatibility(source_asset, asset); + LocalProjectVersionReplacementCandidate { + resource_id: asset.id.clone(), + compatible: compatibility.blocking_reason().is_none(), + compatibility, + blocked_reason: compatibility.blocking_reason(), + warning: compatibility.warning(), + } + }) + .collect(); + + Ok(ReadLocalProjectVersionReplacementCandidatesResult { + source_version_id: source_version_id.to_string(), + source_resource_id: source_resource_id.to_string(), + candidates, + }) +} + +/// 把源版本引用的 `source_resource_id` **直接替换**成 `replacement_resource_id`。 +/// +/// 语义与拒绝路径: +/// - **不追加新版本**:只改该版本的绑定集合,其余版本整条不动、版本数量不变; +/// - 硬门禁:分类不同 / 类型不同 → 拒绝并说明哪一项不等,**不做假成功**; +/// - 源版本不存在 / 源版本未绑定该素材 / 替换素材未登记 / 替换素材与源素材相同 → 拒绝; +/// - CAS:`expectedProjectId` 与 `expectedProjectRevision` 必须与锁内读到的事实一致, +/// 任何拒绝都保证 manifest 与 revision 不变; +/// - 成功后推进一次项目 revision(改绑定属于 `versions` 变化,跨面快照门禁要求 revision 前进), +/// 并追加一条 `asset.version_binding.replace` 审计。审计写失败会把错误报出,但 manifest 已落盘 +/// (与 `asset.register` 同口径,不做回滚)。 +pub(crate) fn replace_local_project_version_resource_at( + root: &Path, + expected_project_id: &str, + expected_project_revision: u64, + source_version_id: &str, + source_resource_id: &str, + replacement_resource_id: &str, +) -> Result { + if expected_project_revision + > shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + { + return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); + } + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("替换素材 expectedProjectId 不能为空".to_string()); + } + let source_version_id = normalized_version_id(source_version_id)?.to_string(); + let source_resource_id = + normalized_resource_id(source_resource_id, "sourceResourceId")?.to_string(); + let replacement_resource_id = + normalized_resource_id(replacement_resource_id, "replacementResourceId")?.to_string(); + if source_resource_id == replacement_resource_id { + return Err("替换素材与源素材相同".to_string()); + } + + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let _lock = acquire_project_write_lock(root, "asset.register")?; + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } + + let target_revision = expected_project_revision + .checked_add(1) + .ok_or_else(|| "项目 revision 已达到上限".to_string())?; + + let mut applied: Option = None; + mutate_manifest_allowing_version_binding_rewrites( + root, + &|_manifest| vec![source_version_id.clone()], + |manifest| { + // 锁内复核 CAS:项目写锁已持有,此处再读一次 durable revision,把"读 revision → + // 写 manifest"之间的窗口收干,任何漂移都在写入前失败关闭。 + if read_game_creator_agent_runtime_project_revision(root)?.revision + != expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } + if manifest.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let version_index = manifest + .versions + .iter() + .position(|version| version.version_id == source_version_id) + .ok_or_else(|| format!("源项目版本不存在:{source_version_id}"))?; + require_source_binding(&manifest.versions[version_index], &source_resource_id)?; + let source_asset = manifest_asset(manifest, &source_resource_id)?.clone(); + let replacement_asset = manifest_asset(manifest, &replacement_resource_id)?.clone(); + let compatibility = version_resource_compatibility(&source_asset, &replacement_asset); + if let Some(reason) = compatibility.blocking_reason() { + return Err(format!("resource-replacement-incompatible:{reason}")); + } + + // 绑定改写 = 源素材从该版本的绑定集合里消失 + 保证替换素材在集合里。 + let slot_id = version_binding_slot_id(&source_resource_id); + let version = &mut manifest.versions[version_index]; + let source_index = version + .resource_bindings + .iter() + .position(|binding| { + binding.slot_id == slot_id && binding.resource_id == source_resource_id + }) + .ok_or_else(|| { + format!("源版本未绑定该素材:{source_version_id} · {source_resource_id}") + })?; + let mut resource_bindings: Vec = version + .resource_bindings + .iter() + .filter(|binding| { + !(binding.slot_id == slot_id && binding.resource_id == source_resource_id) + }) + .cloned() + .collect(); + if !resource_bindings + .iter() + .any(|binding| binding.resource_id == replacement_resource_id) + { + resource_bindings.insert( + source_index.min(resource_bindings.len()), + GameIterationVersionResourceBinding { + slot_id: version_binding_slot_id(&replacement_resource_id), + resource_id: replacement_resource_id.clone(), + }, + ); + } + version.resource_bindings = resource_bindings; + applied = Some(compatibility); + Ok(()) + }, + )?; + + let committed_project_revision = advance_agent_runtime_project_revision_locked(root) + .map_err(|error| format!("绑定已改写,但项目 revision 未能推进:{error}"))?; + let Some(compatibility) = applied else { + return Err("替换写入未产生结果".to_string()); + }; + if committed_project_revision != target_revision { + return Err(format!( + "绑定已改写,但项目 revision 推进结果与预期不一致:期望 {target_revision},实际 {committed_project_revision}" + )); + } + + append_agent_db_record( + root, + serde_json::json!({ + "recordType": VERSION_BINDING_REPLACE_RECORD_TYPE, + "versionId": source_version_id, + "sourceResourceId": source_resource_id, + "replacementResourceId": replacement_resource_id, + "projectRevision": committed_project_revision, + }), + )?; + + Ok(ReplaceLocalProjectVersionResourceResult { + version_id: source_version_id.clone(), + committed_project_revision, + replacement: ProjectVersionResourceReplacement { + version_id: source_version_id, + source_resource_id, + replacement_resource_id, + compatibility, + warning: compatibility.warning(), + }, + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 0bac3d6cd..d78b0a4fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -6078,3 +6078,4 @@ mod response_stream; mod runtime_actions; mod runtime_state; mod sessions; +mod version_resource_replacement; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs new file mode 100644 index 000000000..74a26b4d4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs @@ -0,0 +1,828 @@ +use super::*; +use shared_contracts::game_creation_app::{ + GameCreationAppAssetCategory, GameCreationAppImageSequenceFrame, +}; + +/// 「直接替换」用例:改 manifest 里该版本的绑定,**不产生新版本**。 +/// +/// 覆盖:绑定改写的两条路径(1:1 交换 / 替换素材早已绑定)、除绑定外一切不动、版本数量不变、 +/// 硬门禁(分类 / 类型)拒绝且零副作用、尺寸规格只提示不拒绝、CAS、拒绝路径、候选读取、 +/// 读时自愈口径,以及审计记录。 + +fn replacement_project_fixture() -> PathBuf { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "资源替换口径测试").expect("init project"); + root +} + +fn write_replacement_fixture_file(root: &Path, relative_path: &str) { + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create replacement fixture parent"); + } + fs::write(&path, relative_path.as_bytes()).expect("write replacement fixture asset"); +} + +fn replacement_generated_asset_source(task_id: &str) -> GameCreationAppAssetSource { + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: Some(task_id.to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + } +} + +/// 登记一个素材并返回它的 manifest 资产 ID。 +fn register_replacement_fixture_asset( + root: &Path, + relative_path: &str, + kind: &str, + media_type: &str, + task_id: &str, +) -> String { + write_replacement_fixture_file(root, relative_path); + register_local_asset_at( + root, + relative_path, + kind, + media_type, + "generated", + replacement_generated_asset_source(task_id), + ) + .expect("register replacement fixture asset"); + read_manifest_for_project(root) + .expect("read manifest after registration") + .assets + .iter() + .find(|asset| asset.local_path == relative_path) + .expect("replacement fixture asset registered") + .id + .clone() +} + +fn replacement_manifest_path(root: &Path) -> PathBuf { + root.join(".agent/manifest.json") +} + +fn replacement_manifest_bytes(root: &Path) -> Vec { + fs::read(replacement_manifest_path(root)).expect("read installed manifest bytes") +} + +fn replacement_project_revision(root: &Path) -> u64 { + read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision") + .revision +} + +/// 让当前 revision 成为一个正式版本,绑定当时 manifest 里的全部素材。 +fn register_replacement_initial_version(root: &Path) -> String { + let revision = + advance_agent_runtime_project_revision_locked(root).expect("advance project revision"); + assert!( + ensure_initial_game_iteration_version_at(root, revision).expect("create initial version"), + "initial version should be appended" + ); + read_manifest_for_project(root) + .expect("read manifest with initial version") + .versions + .last() + .expect("initial version exists") + .version_id + .clone() +} + +/// 直接改写 manifest(模拟存量数据 / 帧与时长事实),用于构造落盘字段。 +fn rewrite_replacement_manifest( + root: &Path, + mutate: impl FnOnce(&mut GameCreationAppManifest), +) -> GameCreationAppManifest { + let (path, mut manifest) = read_or_create_manifest(root).expect("read manifest for rewrite"); + mutate(&mut manifest); + write_manifest(&path, &manifest).expect("write rewritten manifest"); + manifest +} + +fn replace_at_current_revision( + root: &Path, + source_version_id: &str, + source_resource_id: &str, + replacement_resource_id: &str, +) -> Result { + replace_local_project_version_resource_at( + root, + "project-1", + replacement_project_revision(root), + source_version_id, + source_resource_id, + replacement_resource_id, + ) +} + +fn candidate_for<'a>( + result: &'a ReadLocalProjectVersionReplacementCandidatesResult, + resource_id: &str, +) -> &'a LocalProjectVersionReplacementCandidate { + result + .candidates + .iter() + .find(|candidate| candidate.resource_id == resource_id) + .unwrap_or_else(|| panic!("candidate missing: {resource_id}")) +} + +fn replacement_audit_log(root: &Path) -> String { + fs::read_to_string(root.join(".agent/agent.db")).unwrap_or_default() +} + +/// 主链路:只改该版本的绑定,**不产生新版本**,其余版本与其余字段逐字不动。 +#[test] +fn replacement_rewrites_the_version_binding_without_adding_a_version() { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/hero-a.png", + "character", + "image/png", + "art-a", + ); + let untouched_asset = register_replacement_fixture_asset( + &root, + "assets/scene.png", + "scene", + "image/png", + "art-scene", + ); + let source_version_id = register_replacement_initial_version(&root); + // 版本创建之后才登记替换素材:绑定改写就是一次 1:1 交换。 + let replacement_asset = register_replacement_fixture_asset( + &root, + "assets/hero-b.png", + "character", + "image/png", + "art-b", + ); + + let before = read_manifest_for_project(&root).expect("read manifest before replacement"); + let source_version = before + .versions + .iter() + .find(|version| version.version_id == source_version_id) + .expect("source version exists") + .clone(); + assert!( + !source_version + .resource_bindings + .iter() + .any(|binding| binding.resource_id == replacement_asset), + "夹具前提:替换素材不在该版本绑定里" + ); + let revision_before = replacement_project_revision(&root); + + let result = replace_at_current_revision( + &root, + &source_version_id, + &source_asset, + &replacement_asset, + ) + .expect("replace version resource"); + + assert_eq!(result.version_id, source_version_id, "被改的就是源版本本身"); + assert_eq!(result.committed_project_revision, revision_before + 1); + assert_eq!(result.replacement.version_id, source_version_id); + assert_eq!(result.replacement.source_resource_id, source_asset); + assert_eq!(result.replacement.replacement_resource_id, replacement_asset); + assert_eq!(result.replacement.warning, None); + assert!( + result.replacement.compatibility.category_equal + && result.replacement.compatibility.subtype_equal + ); + + let after = read_manifest_for_project(&root).expect("read manifest after replacement"); + // 关键:版本数量不变(没有追加新版本),磁盘上的既有记录除绑定外逐字段不变。 + assert_eq!( + after.versions.len(), + before.versions.len(), + "直接替换不得新增或删除版本" + ); + let rewritten = after + .versions + .iter() + .find(|version| version.version_id == source_version_id) + .expect("source version still exists"); + assert_eq!(rewritten.version_id, source_version.version_id); + assert_eq!(rewritten.parent_version_id, source_version.parent_version_id); + assert_eq!(rewritten.project_revision, source_version.project_revision); + assert_eq!(rewritten.created_reason, source_version.created_reason); + assert_eq!(rewritten.created_at, source_version.created_at); + assert_eq!(rewritten.edit_prompt, source_version.edit_prompt); + + // 绑定按 1:1 交换:长度不变、顺序不变,只把源素材换成替换素材。 + assert_eq!( + rewritten.resource_bindings.len(), + source_version.resource_bindings.len() + ); + for (index, original_binding) in source_version.resource_bindings.iter().enumerate() { + let rewritten_binding = &rewritten.resource_bindings[index]; + if original_binding.resource_id == source_asset { + assert_eq!(rewritten_binding.resource_id, replacement_asset); + assert_eq!( + rewritten_binding.slot_id, + format!("asset:{replacement_asset}") + ); + } else { + assert_eq!(rewritten_binding, original_binding); + } + } + assert!( + rewritten + .resource_bindings + .iter() + .all(|binding| binding.resource_id != source_asset), + "源素材必须从该版本的绑定里消失" + ); + assert!( + rewritten + .resource_bindings + .iter() + .any(|binding| binding.resource_id == untouched_asset), + "未命中的绑定必须原样保留" + ); + + // 素材表与磁盘文件不因替换而变化。 + assert_eq!(after.assets, before.assets, "替换不得改动素材登记"); + assert!(root.join("assets/hero-a.png").is_file()); + assert!(root.join("assets/hero-b.png").is_file()); + + // 审计:直接替换没有可回溯的版本历史,这一条就是替换前身份的留痕。 + let audit = replacement_audit_log(&root); + assert!( + audit.contains("asset.version_binding.replace"), + "必须留下替换审计:{audit}" + ); + assert!(audit.contains(&source_version_id)); + assert!(audit.contains(&source_asset)); + assert!(audit.contains(&replacement_asset)); + + fs::remove_dir_all(root).ok(); +} + +/// 替换素材在源版本创建时就已登记(因此已在绑定里)时:只摘掉源素材,不重复添加替换素材。 +#[test] +fn replacement_drops_source_binding_when_replacement_is_already_bound() { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/legacy.png", + "character", + "image/png", + "art-legacy", + ); + let already_bound_replacement = register_replacement_fixture_asset( + &root, + "assets/final.png", + "character", + "image/png", + "art-final", + ); + let source_version_id = register_replacement_initial_version(&root); + let before = read_manifest_for_project(&root).expect("read manifest before replacement"); + let source_version = before + .versions + .iter() + .find(|version| version.version_id == source_version_id) + .expect("source version exists") + .clone(); + + replace_at_current_revision( + &root, + &source_version_id, + &source_asset, + &already_bound_replacement, + ) + .expect("替换素材已绑定时仍必须允许替换"); + + let after = read_manifest_for_project(&root).expect("read manifest after replacement"); + assert_eq!(after.versions.len(), before.versions.len()); + let rewritten = after + .versions + .iter() + .find(|version| version.version_id == source_version_id) + .expect("source version still exists"); + assert_eq!( + rewritten.resource_bindings.len() + 1, + source_version.resource_bindings.len(), + "替换素材已绑定时,只比替换前少掉源素材这一条" + ); + assert!( + rewritten + .resource_bindings + .iter() + .all(|binding| binding.resource_id != source_asset), + "源素材必须从该版本的绑定里消失" + ); + assert!( + rewritten + .resource_bindings + .iter() + .any(|binding| binding.resource_id == already_bound_replacement), + "替换素材必须仍在绑定里" + ); + let mut slot_ids: Vec<&str> = rewritten + .resource_bindings + .iter() + .map(|binding| binding.slot_id.as_str()) + .collect(); + slot_ids.sort_unstable(); + let unique = slot_ids.len(); + slot_ids.dedup(); + assert_eq!(slot_ids.len(), unique, "绑定槽位不得重复"); + + fs::remove_dir_all(root).ok(); +} + +/// 硬门禁只有分类与类型;尺寸规格(这里退化为媒体格式)只提示、不拒绝。 +#[test] +fn replacement_blocks_category_and_subtype_mismatch_but_only_hints_size_spec() { + struct BlockingCase { + name: &'static str, + source_kind: &'static str, + target_kind: &'static str, + expected_reason: &'static str, + } + let blocking_cases = [ + BlockingCase { + name: "分类不同", + source_kind: "character", + target_kind: "scene", + expected_reason: "分类不同", + }, + BlockingCase { + name: "类型不同", + source_kind: "character", + target_kind: "character-animation", + expected_reason: "类型不同", + }, + ]; + + for case in blocking_cases { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/source.png", + case.source_kind, + "image/png", + "art-source", + ); + let target_asset = register_replacement_fixture_asset( + &root, + "assets/target.png", + case.target_kind, + "image/png", + "art-target", + ); + let source_version_id = register_replacement_initial_version(&root); + let bytes_before = replacement_manifest_bytes(&root); + let revision_before = replacement_project_revision(&root); + + let error = + replace_at_current_revision(&root, &source_version_id, &source_asset, &target_asset) + .expect_err(&format!("{} 必须被拒绝", case.name)); + assert!( + error.contains(case.expected_reason), + "{} 的拒绝原因必须指出不等的维度,实际:{error}", + case.name + ); + assert_eq!( + replacement_manifest_bytes(&root), + bytes_before, + "{} 被拒绝时 manifest 不得改动", + case.name + ); + assert_eq!( + replacement_project_revision(&root), + revision_before, + "{} 被拒绝时 revision 不得推进", + case.name + ); + + fs::remove_dir_all(root).ok(); + } + + // 同分类同类型、只有媒体格式不同:**必须放行**,只给提示。 + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/hero.png", + "character", + "image/png", + "art-hero", + ); + let source_version_id = register_replacement_initial_version(&root); + let webp_asset = register_replacement_fixture_asset( + &root, + "assets/hero.webp", + "character", + "image/webp", + "art-hero-webp", + ); + + let candidates = + read_local_project_version_replacement_candidates_at(&root, &source_version_id, &source_asset) + .expect("read candidates"); + let webp_candidate = candidate_for(&candidates, &webp_asset); + assert!( + webp_candidate.compatible, + "格式不同不得阻断选中(硬门禁只有分类与类型)" + ); + assert_eq!(webp_candidate.blocked_reason, None); + assert!(!webp_candidate.compatibility.size_spec_equal); + assert_eq!(webp_candidate.warning, Some("格式与源素材不同")); + + let result = replace_at_current_revision(&root, &source_version_id, &source_asset, &webp_asset) + .expect("同分类同类型的跨格式替换必须成功"); + assert_eq!(result.replacement.warning, Some("格式与源素材不同")); + let after = read_manifest_for_project(&root).expect("read manifest after format replacement"); + let rewritten = after + .versions + .iter() + .find(|version| version.version_id == source_version_id) + .expect("source version still exists"); + assert!( + rewritten + .resource_bindings + .iter() + .any(|binding| binding.resource_id == webp_asset) + ); + + fs::remove_dir_all(root).ok(); +} + +/// 已知尺寸 / 时长事实存在时照常比较,但结果只作为提示。 +/// +/// 每个场景用独立项目:直接替换会把源素材从绑定里摘掉,同一源不能连续替换两次。 +#[test] +fn replacement_hints_known_frame_size_and_duration_differences() { + let frame = |image_src: &str, width: u32, height: u32| GameCreationAppImageSequenceFrame { + image_src: image_src.to_string(), + object_key: None, + asset_object_id: None, + width, + height, + }; + + for (label, target_size, expect_warning) in [ + ("帧尺寸与时长一致", (800_u32, 600_u32, 600_u64), false), + ("帧尺寸与时长不一致", (1024, 768, 900), true), + ] { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/seq-source.png", + "character-animation", + "image/png", + "anim-source", + ); + let target_asset = register_replacement_fixture_asset( + &root, + "assets/seq-target.png", + "character-animation", + "image/png", + "anim-target", + ); + let (target_width, target_height, target_duration) = target_size; + rewrite_replacement_manifest(&root, |manifest| { + for asset in manifest.assets.iter_mut() { + let (width, height, duration) = if asset.id == target_asset { + (target_width, target_height, target_duration) + } else { + (800, 600, 600) + }; + asset.image_sequence_frames = Some(vec![frame("frame-0001.png", width, height)]); + asset.image_sequence_duration_ms = Some(duration); + } + }); + let source_version_id = register_replacement_initial_version(&root); + + let candidates = read_local_project_version_replacement_candidates_at( + &root, + &source_version_id, + &source_asset, + ) + .expect("read candidates"); + let candidate = candidate_for(&candidates, &target_asset); + assert!( + candidate.compatible, + "{label} 时只提示、不阻断(尺寸判据不完整,不完整到不能用它拒绝)" + ); + + let result = replace_at_current_revision(&root, &source_version_id, &source_asset, &target_asset) + .expect("尺寸事实不同也必须允许替换"); + if expect_warning { + assert!(!candidate.compatibility.size_spec_equal, "{label}"); + assert_eq!(candidate.warning, Some("格式与源素材不同"), "{label}"); + assert_eq!( + result.replacement.warning, + Some("格式与源素材不同"), + "{label}" + ); + } else { + assert!(candidate.compatibility.size_spec_equal, "{label}"); + assert_eq!(candidate.warning, None, "{label}"); + assert_eq!(result.replacement.warning, None, "{label}"); + } + + fs::remove_dir_all(root).ok(); + } +} + +/// 读时自愈口径与 `packages/shared` 对齐:落盘 `unclassified` 但 kind 能明确分类时按派生分类比较。 +#[test] +fn replacement_heals_persisted_unclassified_category_like_the_shared_contract() { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/ui-a.png", + "ui-design", + "image/png", + "ui-a", + ); + let healed_asset = register_replacement_fixture_asset( + &root, + "assets/ui-b.png", + "ui-design", + "image/png", + "ui-b", + ); + // 模拟存量误写:落盘值固化为 unclassified,而 kind 已能明确派生出 UI 交互分类。 + rewrite_replacement_manifest(&root, |manifest| { + for asset in manifest.assets.iter_mut() { + if asset.id == healed_asset { + asset.category = GameCreationAppAssetCategory::Unclassified; + } + } + }); + let persisted = read_manifest_for_project(&root).expect("read manifest after heal fixture"); + assert_eq!( + persisted + .assets + .iter() + .find(|asset| asset.id == healed_asset) + .expect("healed asset exists") + .category, + GameCreationAppAssetCategory::Unclassified, + "夹具必须真的落盘 unclassified" + ); + let source_version_id = register_replacement_initial_version(&root); + + let candidates = + read_local_project_version_replacement_candidates_at(&root, &source_version_id, &source_asset) + .expect("read candidates"); + let healed = candidate_for(&candidates, &healed_asset); + assert!( + healed.compatibility.category_equal, + "落盘 unclassified + kind ui-design 必须按读时自愈口径判为同分类" + ); + assert!(healed.compatible); + + let result = + replace_at_current_revision(&root, &source_version_id, &source_asset, &healed_asset) + .expect("healed category must allow replacement"); + assert!(result.replacement.compatibility.category_equal); + + fs::remove_dir_all(root).ok(); +} + +/// 换不到源版本 / 源素材不在该版本绑定里 / 目标未登记 / 目标就是源素材:全部拒绝且零副作用。 +#[test] +fn replacement_rejects_unresolvable_source_and_target() { + let root = replacement_project_fixture(); + let bound_asset = register_replacement_fixture_asset( + &root, + "assets/bound.png", + "character", + "image/png", + "art-bound", + ); + let source_version_id = register_replacement_initial_version(&root); + // 初始版本创建之后才登记的素材:不在该版本的绑定里。 + let unbound_asset = register_replacement_fixture_asset( + &root, + "assets/late.png", + "character", + "image/png", + "art-late", + ); + let revision_before = replacement_project_revision(&root); + let bytes_before = replacement_manifest_bytes(&root); + + let missing_version = + replace_at_current_revision(&root, "absent-version", &bound_asset, &unbound_asset) + .expect_err("未知源版本必须被拒绝"); + assert!( + missing_version.contains("源项目版本不存在"), + "unexpected error: {missing_version}" + ); + + let unbound_source = + replace_at_current_revision(&root, &source_version_id, &unbound_asset, &bound_asset) + .expect_err("源版本未绑定的素材必须被拒绝"); + assert!( + unbound_source.contains("源版本未绑定该素材"), + "unexpected error: {unbound_source}" + ); + + let unregistered_target = + replace_at_current_revision(&root, &source_version_id, &bound_asset, "absent-asset") + .expect_err("未登记的目标素材必须被拒绝"); + assert!( + unregistered_target.contains("项目资源不存在"), + "unexpected error: {unregistered_target}" + ); + + let same_target = + replace_at_current_revision(&root, &source_version_id, &bound_asset, &bound_asset) + .expect_err("目标与源相同必须被拒绝"); + assert!( + same_target.contains("替换素材与源素材相同"), + "unexpected error: {same_target}" + ); + + assert_eq!(replacement_manifest_bytes(&root), bytes_before); + assert_eq!(replacement_project_revision(&root), revision_before); + + fs::remove_dir_all(root).ok(); +} + +/// CAS:陈旧 revision 与跨项目身份都拒绝,manifest 与 revision 不变。 +#[test] +fn replacement_enforces_revision_and_identity_cas() { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/cas-source.png", + "character", + "image/png", + "art-cas-source", + ); + let target_asset = register_replacement_fixture_asset( + &root, + "assets/cas-target.png", + "character", + "image/png", + "art-cas-target", + ); + let source_version_id = register_replacement_initial_version(&root); + let revision_before = replacement_project_revision(&root); + let bytes_before = replacement_manifest_bytes(&root); + + let stale = replace_local_project_version_resource_at( + &root, + "project-1", + revision_before + 1, + &source_version_id, + &source_asset, + &target_asset, + ) + .expect_err("陈旧 revision 必须被拒绝"); + assert_eq!(stale, "project-revision-conflict"); + + let identity = replace_local_project_version_resource_at( + &root, + "project-2", + revision_before, + &source_version_id, + &source_asset, + &target_asset, + ) + .expect_err("跨项目身份必须被拒绝"); + assert_eq!(identity, "project-identity-conflict"); + + assert_eq!(replacement_manifest_bytes(&root), bytes_before); + assert_eq!(replacement_project_revision(&root), revision_before); + + fs::remove_dir_all(root).ok(); +} + +/// 候选读取:源素材排除、按 manifest 顺序返回、逐项给出后端权威结论与原因;只读。 +#[test] +fn replacement_candidates_report_authoritative_compatibility() { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/pick-source.png", + "character", + "image/png", + "art-pick-source", + ); + let compatible_asset = register_replacement_fixture_asset( + &root, + "assets/pick-compatible.png", + "character", + "image/png", + "art-pick-compatible", + ); + let category_mismatch = register_replacement_fixture_asset( + &root, + "assets/pick-scene.png", + "scene", + "image/png", + "art-pick-scene", + ); + let subtype_mismatch = register_replacement_fixture_asset( + &root, + "assets/pick-animation.png", + "character-animation", + "image/png", + "art-pick-animation", + ); + let format_mismatch = register_replacement_fixture_asset( + &root, + "assets/pick-webp.webp", + "character", + "image/webp", + "art-pick-webp", + ); + let source_version_id = register_replacement_initial_version(&root); + let revision_before = replacement_project_revision(&root); + let bytes_before = replacement_manifest_bytes(&root); + + let result = + read_local_project_version_replacement_candidates_at(&root, &source_version_id, &source_asset) + .expect("read replacement candidates"); + + assert_eq!(result.source_version_id, source_version_id); + assert_eq!(result.source_resource_id, source_asset); + assert_eq!(result.candidates.len(), 4); + let ordered: Vec<&str> = result + .candidates + .iter() + .map(|candidate| candidate.resource_id.as_str()) + .collect(); + assert_eq!( + ordered, + vec![ + compatible_asset.as_str(), + category_mismatch.as_str(), + subtype_mismatch.as_str(), + format_mismatch.as_str(), + ], + "候选按 manifest.assets 顺序返回且排除源素材" + ); + + let compatible = candidate_for(&result, &compatible_asset); + assert!(compatible.compatible); + assert_eq!(compatible.blocked_reason, None); + assert_eq!(compatible.warning, None); + + let category = candidate_for(&result, &category_mismatch); + assert!(!category.compatible); + assert!(!category.compatibility.category_equal); + assert_eq!(category.blocked_reason, Some("分类不同")); + + let subtype = candidate_for(&result, &subtype_mismatch); + assert!(!subtype.compatible); + assert!(subtype.compatibility.category_equal); + assert!(!subtype.compatibility.subtype_equal); + assert_eq!(subtype.blocked_reason, Some("类型不同")); + + let format = candidate_for(&result, &format_mismatch); + assert!(format.compatible, "格式不同不阻断"); + assert_eq!(format.blocked_reason, None); + assert!(!format.compatibility.size_spec_equal); + assert_eq!(format.warning, Some("格式与源素材不同")); + + // 只读:不改 manifest、不推进 revision。 + assert_eq!(replacement_manifest_bytes(&root), bytes_before); + assert_eq!(replacement_project_revision(&root), revision_before); + + // 版本创建之后才登记的素材不在该版本绑定里:读它的候选必须失败关闭。 + let late_asset = register_replacement_fixture_asset( + &root, + "assets/pick-late.png", + "character", + "image/png", + "art-pick-late", + ); + let unbound = + read_local_project_version_replacement_candidates_at(&root, &source_version_id, &late_asset) + .expect_err("源版本未绑定的素材不能读候选:替换没有源绑定可言"); + assert!( + unbound.contains("源版本未绑定该素材"), + "unexpected error: {unbound}" + ); + + let blank_version = + read_local_project_version_replacement_candidates_at(&root, " ", &source_asset) + .expect_err("空 sourceVersionId 必须被拒绝"); + assert!( + blank_version.contains("sourceVersionId"), + "unexpected error: {blank_version}" + ); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts new file mode 100644 index 000000000..316d481ea --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts @@ -0,0 +1,199 @@ +import type { EditorAsset } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; +import { + resourceCanvasAssetKind, + resourceCanvasMediaType, +} from './resourceCanvasToolbarModel'; + +/** + * 「替换素材」的展示层口径:类型、禁用原因、提示与失败文案。 + * + * 兼容性判据在 Rust(`project/version_resource_replacement.rs`),前端只做呈现,不在这里重算 + * 判据 —— 否则同一个规则会出现两份实现,迟早分叉。当前形态是**直接替换**:改 manifest 里该 + * 版本的绑定,不产生新版本。 + */ + +/** + * 兼容性三项。只有前两项是硬门禁(不满足则候选不可选);`sizeSpecEqual` 只作提示。 + */ +export type ProjectVersionResourceCompatibility = { + categoryEqual: boolean; + subtypeEqual: boolean; + sizeSpecEqual: boolean; +}; + +export type LocalProjectVersionReplacementCandidate = { + resourceId: string; + compatible: boolean; + compatibility: ProjectVersionResourceCompatibility; + blockedReason: string | null; + /** 非阻断提示(当前只有"格式与源素材不同")。 */ + warning: string | null; +}; + +export type ReadLocalProjectVersionReplacementCandidatesResult = { + sourceVersionId: string; + sourceResourceId: string; + candidates: LocalProjectVersionReplacementCandidate[]; +}; + +/** 这一次替换的记录:哪个版本的哪个绑定指向改成了哪个素材(没有新版本,也就没有 parentVersionId)。 */ +export type ProjectVersionResourceReplacement = { + versionId: string; + sourceResourceId: string; + replacementResourceId: string; + compatibility: ProjectVersionResourceCompatibility; + warning: string | null; +}; + +export type ReplaceLocalProjectVersionResourceResult = { + versionId: string; + committedProjectRevision: number; + replacement: ProjectVersionResourceReplacement; +}; + +/** + * 候选不可选的原因:**只看硬门禁**(分类 / 类型)。 + * + * 后端已经给出 `blockedReason`;这里只在它缺失(旧后端 / 手工构造的候选)时按同一顺序派生, + * 不让界面出现"不可选但不说原因"的条目。尺寸规格不参与该判据:它今天不完整,用不完整的判据 + * 拒绝会误伤 `png ↔ webp` 这类正常替换。 + */ +export function resourceReplacementBlockedReason( + candidate: LocalProjectVersionReplacementCandidate, +): string | null { + if (candidate.compatible) return null; + const reason = candidate.blockedReason?.trim(); + if (reason) return reason; + if (!candidate.compatibility.categoryEqual) return '分类不同'; + if (!candidate.compatibility.subtypeEqual) return '类型不同'; + return '替换兼容性未通过'; +} + +/** 候选的非阻断提示:可选但仍值得说一声(当前只有尺寸规格/格式差异)。 */ +export function resourceReplacementWarning( + candidate: LocalProjectVersionReplacementCandidate, +): string | null { + const warning = candidate.warning?.trim(); + if (warning) return warning; + if ( + candidate.compatible && + candidate.compatibility.categoryEqual && + candidate.compatibility.subtypeEqual && + !candidate.compatibility.sizeSpecEqual + ) { + return '格式与源素材不同'; + } + return null; +} + +/** + * 候选 → 禁用原因表,喂给弹窗的 `assetBlockedReasons`。 + * + * 不兼容候选**渲染但禁用**:隐藏会让用户以为"素材不存在",而真实原因是它不能替换这个素材。 + */ +export function resourceReplacementBlockedReasons( + candidates: readonly LocalProjectVersionReplacementCandidate[], +): Record { + const reasons: Record = {}; + for (const candidate of candidates) { + const reason = resourceReplacementBlockedReason(candidate); + if (reason) reasons[candidate.resourceId] = reason; + } + return reasons; +} + +/** 候选 → 提示表,喂给弹窗的 `assetHints`(默认不渲染,只有传了才显示)。 */ +export function resourceReplacementAssetHints( + candidates: readonly LocalProjectVersionReplacementCandidate[], +): Record { + const hints: Record = {}; + for (const candidate of candidates) { + const warning = resourceReplacementWarning(candidate); + if (warning) hints[candidate.resourceId] = warning; + } + return hints; +} + +/** + * 候选 → 弹窗素材。 + * + * `src` 固定为空字符串:AGC 的原生预览读取走带 scope 的调度器 + Blob URL,弹窗里拿不到同步 + * `src`,所以缩略图由调用方通过弹窗的 `renderAssetMedia` opt-in 渲染,**不给 `` 喂空串** + * (那会挂破图)。分类筛选复用语资源画布同一条媒体类型口径。 + */ +export function resourceReplacementPickerAssets( + resources: readonly ProjectResource[], + candidates: readonly LocalProjectVersionReplacementCandidate[], +): EditorAsset[] { + const resourceByAssetId = new Map(); + for (const resource of resources) { + const assetId = resource.manifestAssetId; + if (assetId && !resourceByAssetId.has(assetId)) { + resourceByAssetId.set(assetId, resource); + } + } + return candidates.flatMap((candidate) => { + const resource = resourceByAssetId.get(candidate.resourceId); + if (!resource) return []; + const assetKind = resourceCanvasAssetKind(resource); + return [ + { + id: candidate.resourceId, + label: resource.label, + src: '', + mediaType: resourceCanvasMediaType(resource) ?? 'image', + width: 0, + height: 0, + folderId: 'project-assets', + sourceKind: 'uploaded', + sourceType: 'generated', + persisted: true, + ...(assetKind ? { assetKind } : {}), + }, + ]; + }); +} + +/** 入口判据复用现役口径:`isResourceUsedByCurrentVersion`(manifest 身份 + 被当前版本绑定)。 */ + +/** + * 把 Rust 的结构化拒绝翻成用户可读中文。 + * + * 不认识的错误原样透出:宁可显示后端原文,也不把原因吞掉换成"替换失败"这种无信息文案。 + */ +export function resourceVersionReplacementErrorMessage(error: unknown): string { + const message = + error instanceof Error ? error.message : String(error ?? '').trim(); + if (!message) return '替换素材失败'; + if (message.includes('project-revision-conflict')) { + return '项目已被其它操作改动,请重试替换'; + } + if (message.includes('project-identity-conflict')) { + return '项目身份不一致,请重新打开项目后再试'; + } + if (message.includes('resource-replacement-incompatible')) { + const reason = message.split(':').pop()?.trim(); + return reason ? `替换素材不兼容:${reason}` : '替换素材不兼容'; + } + if (message.includes('源项目版本不存在')) { + return '当前版本已不存在,请刷新项目后重试'; + } + if (message.includes('源版本未绑定该素材')) { + return '该素材不在当前版本的绑定里,不能替换'; + } + if (message.includes('项目资源不存在')) { + return '替换素材未登记或已被删除'; + } + if (message.includes('替换素材与源素材相同')) { + return '替换素材与源素材相同'; + } + if (message.includes('不可修改、删除或重排')) { + // 版本记录写入边界的拒绝:直接替换只允许改一个版本的绑定,走到这里说明写入被守门挡下了。 + return '替换被项目版本写入边界拒绝,请刷新项目后重试'; + } + if (message.includes('必须在客户端内执行')) { + return '替换素材需要在客户端内执行'; + } + return message; +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementTransport.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementTransport.ts new file mode 100644 index 000000000..c6f8abab3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementTransport.ts @@ -0,0 +1,49 @@ +import type { + ReadLocalProjectVersionReplacementCandidatesResult, + ReplaceLocalProjectVersionResourceResult, +} from './resourceVersionReplacementModel'; + +/** + * 版本级资源替换的 IPC 边界。 + * + * 两个命令都只吃单个 `input` 对象:Rust 侧入参是 `deny_unknown_fields` 的结构体, + * 多传字段会直接失败关闭。 + */ + +function replacementInvoke() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + // 与素材删除同口径:不在客户端内执行时明确报错,不做"看起来成功"的静默返回。 + throw new Error('替换素材需要在客户端内执行'); + } + return invoke; +} + +/** 读取源素材可用的替换候选与后端权威的三项兼容性结论(只读)。 */ +export async function readVersionResourceReplacementCandidates(input: { + projectPath: string; + sourceVersionId: string; + sourceResourceId: string; +}): Promise { + const invoke = replacementInvoke(); + return invoke( + 'read_local_project_version_resource_replacement_candidates', + { input }, + ); +} + +/** 改 manifest 绑定并追加下一迭代版本(不回滚:成功即已落盘)。 */ +export async function replaceVersionResource(input: { + projectPath: string; + expectedProjectId: string; + expectedProjectRevision: number; + sourceVersionId: string; + sourceResourceId: string; + replacementResourceId: string; +}): Promise { + const invoke = replacementInvoke(); + return invoke( + 'replace_local_project_version_resource', + { input }, + ); +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index d4fae72f1..6f853e8a5 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -6412,6 +6412,21 @@ iframe.preview-frame { color: #9b5537; } +/* + * 「替换素材」候选弹窗里的类型占位。 + * + * AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL,弹窗内没有同步 `src`, + * 所以候选行只渲染稳定类型图标,不挂 ``、不出现破图。 + */ +.game-resource-replacement-media { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #8d7a6b; +} + .game-attachment-errors { display: grid; gap: 4px; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 7d7c8105f..4b77248db 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -35,6 +35,7 @@ import { Play, Plus, Redo2, + Replace, RotateCcw, Search, Settings2, @@ -42,6 +43,7 @@ import { Sparkles, Undo2, Users, + Video, X, ZoomOut, } from 'lucide-react'; @@ -83,6 +85,7 @@ import { CHARACTER_ANIMATION_DURATION_OPTIONS, CHARACTER_ANIMATION_MODEL, } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; +import { ImageCanvasProjectAssetPickerDialog } from '../../../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog'; import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; @@ -93,6 +96,7 @@ import { } from '../../features/project-workspace/LocalGamePreviewFrame'; import { dispatchResourceReferenceInsert, + resolveActiveIterationVersion, resourceReferenceCategoryLabel, } from '../../features/project-workspace/resourceReferences'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; @@ -142,6 +146,17 @@ import { isResourceUsedByCurrentVersion, } from '../../features/resource-canvas/resourceCanvasVersionBindingModel'; import { ResourcePromptPolishSlot } from '../../features/resource-canvas/ResourcePromptPolishSlot'; +import { + type LocalProjectVersionReplacementCandidate, + resourceReplacementAssetHints, + resourceReplacementBlockedReasons, + resourceReplacementPickerAssets, + resourceVersionReplacementErrorMessage, +} from '../../features/resource-canvas/resourceVersionReplacementModel'; +import { + readVersionResourceReplacementCandidates, + replaceVersionResource, +} from '../../features/resource-canvas/resourceVersionReplacementTransport'; import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge'; import { currentPlatformSessionGeneration, @@ -1431,6 +1446,24 @@ export default function ProjectDevelopmentView({ const [resourceRenameError, setResourceRenameError] = useState( null, ); + /** + * 版本级资源替换(PRD §3.2 / §5.3):改 manifest 绑定,落盘为"新版本 + 新绑定"。 + * + * 源身份在打开弹窗时冻结(`sourceVersionId + sourceResourceId`),候选与兼容性结论全部来自 + * Rust;前端不重算判据,也不在失败时伪造成功。 + */ + const [resourceReplacementSource, setResourceReplacementSource] = useState<{ + versionId: string; + assetId: string; + } | null>(null); + const [resourceReplacementCandidates, setResourceReplacementCandidates] = + useState([]); + const [resourceReplacementOpen, setResourceReplacementOpen] = useState(false); + const [resourceReplacementLoading, setResourceReplacementLoading] = + useState(false); + const [resourceReplacementError, setResourceReplacementError] = useState< + string | null + >(null); const [pendingResourceEditActionIds, setPendingResourceEditActionIds] = useState>(() => new Set()); const [pendingResourceEditActionErrors, setPendingResourceEditActionErrors] = @@ -5249,16 +5282,142 @@ export default function ProjectDevelopmentView({ * * 画面本身不需要运行时按版本重映射:素材不可变(编辑产出新素材而不是改文件), * 所以版本之间没变的资源本来就是同一份文件,重载预览即可回到该版本对应的画面。 + * + * 用 `useCallback` 固定身份:素材替换成功后也要走这条记录层通道(见 + * `confirmResourceVersionReplacement`),普通函数声明会让依赖数组每次渲染都变。 */ - function selectActiveVersion(versionId: string) { - if (versionId === activeVersionId) { - return; - } - onActiveVersionChange?.(versionId); - if (mode === 'run' && runAvailable) { - onPlay?.(); - } - } + const selectActiveVersion = useCallback( + (versionId: string) => { + if (versionId === activeVersionId) { + return; + } + onActiveVersionChange?.(versionId); + if (mode === 'run' && runAvailable) { + onPlay?.(); + } + }, + [activeVersionId, mode, onActiveVersionChange, onPlay, runAvailable], + ); + /** + * 打开「替换素材」:先向后端要一次权威候选,拿到了才开弹窗。 + * + * 只读失败(项目未初始化 / 源版本已不存在)时不弹空壳弹窗,直接把原因说明白, + * 避免出现"打开了但什么也选不了"的假入口。 + * + * 本组处理函数放在 `selectActiveVersion` 之后:替换成功要走同一条记录层切换通道, + * 而 `useCallback` 的依赖数组在渲染期求值,声明顺序必须先于它。 + */ + const openResourceVersionReplacement = useCallback( + async (resource: ProjectResource) => { + const assetId = resource.manifestAssetId; + const version = resolveActiveIterationVersion( + projectVersions, + activeVersionId, + ); + if (!assetId || !version) return; + const source = { versionId: version.versionId, assetId }; + setResourceReplacementError(null); + setResourceReplacementCandidates([]); + setResourceReplacementSource(source); + setResourceReplacementLoading(true); + try { + const result = await readVersionResourceReplacementCandidates({ + projectPath, + sourceVersionId: source.versionId, + sourceResourceId: source.assetId, + }); + setResourceReplacementCandidates(result.candidates); + setResourceReplacementOpen(true); + } catch (error) { + setResourceReplacementSource(null); + setResourceWorkbenchNotice( + resourceVersionReplacementErrorMessage(error), + ); + } finally { + setResourceReplacementLoading(false); + } + }, + [activeVersionId, projectPath, projectVersions], + ); + const closeResourceVersionReplacement = useCallback(() => { + setResourceReplacementOpen(false); + setResourceReplacementSource(null); + setResourceReplacementCandidates([]); + setResourceReplacementError(null); + }, []); + /** + * 确认替换:直接改该版本的绑定(不建新版本),成功后重读 manifest。 + * + * 失败一律保留弹窗与选择并把原因显示在弹窗里:不动版本、不动高亮、不提示成功。 + * 成功后**不切换版本**——没有新版本可切;「当前使用」高亮会随新绑定自动移动(同一个源版本)。 + */ + const confirmResourceVersionReplacement = useCallback( + async (assetIds: string[]) => { + const source = resourceReplacementSource; + const replacementResourceId = assetIds[0]; + if (!source || !replacementResourceId) { + setResourceReplacementError('请选择一个替换素材'); + return; + } + if (resourceReplacementLoading) return; + setResourceReplacementLoading(true); + setResourceReplacementError(null); + try { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + throw new Error('替换素材需要在客户端内执行'); + } + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + const result = await replaceVersionResource({ + projectPath, + expectedProjectId: manifest.projectId, + expectedProjectRevision: status.revision, + sourceVersionId: source.versionId, + sourceResourceId: source.assetId, + replacementResourceId, + }); + setResourceReplacementOpen(false); + setResourceReplacementSource(null); + setResourceReplacementCandidates([]); + await reloadManifestAfterAssetCommand( + result.committedProjectRevision, + `version-resource-replacement:${result.versionId}`, + ); + } catch (error) { + setResourceReplacementError( + resourceVersionReplacementErrorMessage(error), + ); + } finally { + setResourceReplacementLoading(false); + } + }, + [ + manifest.projectId, + projectPath, + reloadManifestAfterAssetCommand, + resourceReplacementLoading, + resourceReplacementSource, + ], + ); + const resourceReplacementPickerEntries = useMemo( + () => + resourceReplacementPickerAssets(resources, resourceReplacementCandidates), + [resourceReplacementCandidates, resources], + ); + const resourceReplacementBlockedReasonMap = useMemo( + () => resourceReplacementBlockedReasons(resourceReplacementCandidates), + [resourceReplacementCandidates], + ); + const resourceReplacementHintMap = useMemo( + () => resourceReplacementAssetHints(resourceReplacementCandidates), + [resourceReplacementCandidates], + ); function showRunView() { if (!runAvailable || uiEditorRoute) { @@ -6373,6 +6532,31 @@ export default function ProjectDevelopmentView({ 重命名 ) : null} + {/* + 「替换素材」只在真链路能跑通时才渲染:素材必须是 manifest 资产, + 且被**当前版本**绑定(`currentVersionBindingIds` 就是版本绑定口径的 + 唯一判定,见 `resourceCanvasVersionBindingModel.ts`)。不满足时不渲染 + 按钮,避免出现"点了没反应"的假按钮。 + */} + {selectedResource && + isResourceUsedByCurrentVersion( + selectedResource, + currentVersionBindingIds, + ) ? ( + } + onClick={() => + void openResourceVersionReplacement( + selectedResource, + ) + } + > + 替换素材 + + ) : null} } onOpenQuickEditPanel={openResourceQuickEditPanel} @@ -7093,6 +7277,36 @@ export default function ProjectDevelopmentView({ onConfirm={(newFileName) => void confirmResourceRename(newFileName)} /> ) : null} + {/* + 版本级资源替换(直接替换):复用美术画布的参考图弹窗(单选 + 禁用原因 + 提示 + 失败原因)。 + 缩略图走 `renderAssetMedia` 的类型占位:AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL, + 弹窗里没有同步 `src`,直接给 `` 会挂破图。 + */} + ( + + )} + onCancel={closeResourceVersionReplacement} + onConfirm={(assetIds) => + void confirmResourceVersionReplacement(assetIds) + } + /> {resourceRecoveryPanelOpen ? (
) { + const resources = + (args?.resources as Array<{ resourceId: string }> | undefined) ?? []; + return { + resourceIds: resources.map(({ resourceId }) => resourceId), + referenceEdges: [], + taskFlows: [], + connectionIndex: resources.map(({ resourceId }) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: [], + dependencyDepths: resources.map(({ resourceId }) => ({ + resourceId, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + +function replacementManifest() { + const manifest = createGameCreationAppManifest(PROJECT_ID, '替换素材测试'); + manifest.assets = [ + { + id: 'asset-legacy', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/legacy.png', + source: { kind: 'generated' as const }, + }, + { + id: 'asset-final', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/final.png', + source: { kind: 'generated' as const }, + }, + { + id: 'asset-scene', + kind: 'scene', + mediaType: 'image/png', + localPath: 'assets/scene.png', + source: { kind: 'generated' as const }, + }, + { + id: 'asset-late', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/late.png', + source: { kind: 'generated' as const }, + }, + { + id: 'asset-webp', + kind: 'character', + mediaType: 'image/webp', + localPath: 'assets/final.webp', + source: { kind: 'generated' as const }, + }, + ]; + manifest.versions = [ + { + versionId: SOURCE_VERSION_ID, + parentVersionId: null, + projectRevision: 1, + resourceBindings: [ + { slotId: 'asset:asset-legacy', resourceId: 'asset-legacy' }, + { slotId: 'asset:asset-final', resourceId: 'asset-final' }, + { slotId: 'asset:asset-scene', resourceId: 'asset-scene' }, + ], + createdReason: 'initial', + createdAt: 1_700_000_000, + }, + ]; + return manifest; +} + +const REPLACEMENT_CANDIDATES = { + sourceVersionId: SOURCE_VERSION_ID, + sourceResourceId: 'asset-legacy', + candidates: [ + { + resourceId: 'asset-final', + compatible: true, + compatibility: { + categoryEqual: true, + subtypeEqual: true, + sizeSpecEqual: true, + }, + blockedReason: null, + warning: null, + }, + { + // 同分类同类型、只有媒体格式不同:可选,但带一条提示。 + resourceId: 'asset-webp', + compatible: true, + compatibility: { + categoryEqual: true, + subtypeEqual: true, + sizeSpecEqual: false, + }, + blockedReason: null, + warning: '格式与源素材不同', + }, + { + resourceId: 'asset-scene', + compatible: false, + compatibility: { + categoryEqual: false, + subtypeEqual: false, + sizeSpecEqual: true, + }, + blockedReason: '分类不同', + warning: null, + }, + ], +}; + +// 直接替换:不产生新版本,返回的就是被改的那个版本。 +const REPLACEMENT_RESULT = { + versionId: SOURCE_VERSION_ID, + committedProjectRevision: 6, + replacement: { + versionId: SOURCE_VERSION_ID, + sourceResourceId: 'asset-legacy', + replacementResourceId: 'asset-final', + compatibility: { + categoryEqual: true, + subtypeEqual: true, + sizeSpecEqual: true, + }, + warning: null, + }, +}; + +type RenderOptions = { + replacementCandidates?: () => Promise; + replacementWrite?: () => Promise; +}; + +let observer: ReturnType< + typeof installResourceCardIntersectionObserver +> | null = null; + +function renderReplacementWorkbench(options: RenderOptions = {}) { + observer = installResourceCardIntersectionObserver(); + const manifest = replacementManifest(); + // 直接替换后的 manifest:版本数量不变,只有该版本的绑定被改写。 + const nextManifest = { + ...manifest, + versions: manifest.versions.map((version) => ({ + ...version, + resourceBindings: [ + { slotId: 'asset:asset-final', resourceId: 'asset-final' }, + { slotId: 'asset:asset-scene', resourceId: 'asset-scene' }, + ], + })), + }; + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutRevision, + positions: [], + updatedAt: layoutRevision, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 12, + dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', + }; + } + if (command === 'get_local_game_project_revision') { + return { revision: EXPECTED_REVISION }; + } + if ( + command === 'read_local_project_version_resource_replacement_candidates' + ) { + return options.replacementCandidates + ? options.replacementCandidates() + : REPLACEMENT_CANDIDATES; + } + if (command === 'replace_local_project_version_resource') { + return options.replacementWrite + ? options.replacementWrite() + : REPLACEMENT_RESULT; + } + if (command === 'get_local_game_manifest') { + return nextManifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + const onActiveVersionChange = vi.fn(); + const onPlay = vi.fn(); + const onManifestChange = vi.fn(); + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: PROJECT_PATH, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + activeVersionId: null, + onActiveVersionChange, + onPlay, + onManifestChange, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + return { invoke, onActiveVersionChange, onPlay, onManifestChange }; +} + +async function selectCardAndOpenToolbar(label: string) { + await waitFor(() => + expect( + document.querySelector('.game-resource-book-thumbnail'), + ).not.toBeNull(), + ); + // 浮出工具条只在栏目页(`resourceBookView !== 'main'`)上挂载:先打开素材所在的栏目页。 + if (!document.querySelector('[data-resource-book-view="child"]')) { + fireEvent.click( + await screen.findByRole('button', { name: '打开角色与对象' }), + ); + await waitFor(() => + expect( + document.querySelector('[data-resource-book-view="child"]'), + ).not.toBeNull(), + ); + } + // 卡片必须先被 IntersectionObserver 报为可见,选中后才会浮出工具条; + // 这段 stub 与 `project-development.suite.ts` 的同名 helper 同形,本文件独立成文件后才复制过来。 + act(() => { + if (!observer) throw new Error('IntersectionObserver stub 未安装'); + observer.triggerVisible(); + }); + fireEvent.click(await findResourceSelectButton(label)); + return screen.findByRole('toolbar', { name: '图片工具栏' }); +} + +/** + * 资源卡预览用的 IntersectionObserver stub。 + * + * jsdom 没有 IntersectionObserver,而卡片浮出工具条依赖"可见"这一步,所以用例自己提供它。 + */ +function installResourceCardIntersectionObserver() { + const instances: Array<{ + callback: IntersectionObserverCallback; + observed: Set; + observer: IntersectionObserver; + }> = []; + + class ResourceCardIntersectionObserver { + readonly root = null; + readonly rootMargin = '160px'; + readonly thresholds = [0]; + readonly observed = new Set(); + + constructor(readonly callback: IntersectionObserverCallback) { + instances.push({ + callback, + observed: this.observed, + observer: this as unknown as IntersectionObserver, + }); + } + + observe(element: Element) { + this.observed.add(element); + } + + unobserve(element: Element) { + this.observed.delete(element); + } + + disconnect() { + this.observed.clear(); + } + + takeRecords() { + return []; + } + } + + Object.defineProperty(window, 'IntersectionObserver', { + configurable: true, + value: ResourceCardIntersectionObserver, + }); + + return { + triggerVisible(elements?: Element[]) { + const instance = instances.at(-1); + if (!instance) { + throw new Error('resource card IntersectionObserver was not created'); + } + const targets = elements ?? Array.from(instance.observed); + instance.callback( + targets.map( + (target) => + ({ + target, + isIntersecting: true, + intersectionRatio: 1, + }) as IntersectionObserverEntry, + ), + instance.observer, + ); + }, + }; +} + +describe('版本级资源替换', () => { + it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => { + const { invoke } = renderReplacementWorkbench(); + + // 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。 + const lateToolbar = await selectCardAndOpenToolbar('late.png'); + expect( + within(lateToolbar).queryByRole('button', { name: '替换素材' }), + ).toBeNull(); + expect( + within(lateToolbar).getByRole('button', { name: '快速编辑' }), + ).not.toBeNull(); + expect( + invoke.mock.calls.some( + ([command]) => + command === + 'read_local_project_version_resource_replacement_candidates', + ), + ).toBe(false); + + // 被当前版本绑定的素材:入口出现。 + const sourceToolbar = await selectCardAndOpenToolbar('legacy.png'); + expect( + within(sourceToolbar).getByRole('button', { name: '替换素材' }), + ).not.toBeNull(); + }); + + it('从入口一路走到写入:候选弹窗禁用硬门禁项、给出格式提示、直接替换且不产生新版本', async () => { + const { invoke, onActiveVersionChange, onPlay, onManifestChange } = + renderReplacementWorkbench(); + + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + + await waitFor(() => + expect( + invoke.mock.calls.some( + ([command]) => + command === + 'read_local_project_version_resource_replacement_candidates', + ), + ).toBe(true), + ); + expect(invoke).toHaveBeenCalledWith( + 'read_local_project_version_resource_replacement_candidates', + { + input: { + projectPath: PROJECT_PATH, + sourceVersionId: SOURCE_VERSION_ID, + sourceResourceId: 'asset-legacy', + }, + }, + ); + + const dialog = await screen.findByRole('dialog', { + name: '选择替换素材', + }); + // 候选只列后端给出的素材:未绑定/未登记的素材不合成条目。 + expect( + within(dialog).queryByRole('option', { name: '选择替换素材late.png' }), + ).toBeNull(); + const blockedOption = within(dialog).getByRole('option', { + name: '选择替换素材scene.png', + }) as HTMLButtonElement; + expect(blockedOption.disabled).toBe(true); + expect(within(dialog).getByText('分类不同')).not.toBeNull(); + const compatibleOption = within(dialog).getByRole('option', { + name: '选择替换素材final.png', + }) as HTMLButtonElement; + expect(compatibleOption.disabled).toBe(false); + // 尺寸规格降级为提示:格式不同的候选仍可选,但把差异说清楚。 + const hintedOption = within(dialog).getByRole('option', { + name: '选择替换素材final.webp', + }) as HTMLButtonElement; + expect(hintedOption.disabled).toBe(false); + expect(within(dialog).getByText('格式与源素材不同')).not.toBeNull(); + + fireEvent.click(compatibleOption); + fireEvent.click( + within(dialog).getByRole('button', { name: '确认选择替换素材' }), + ); + + await waitFor(() => + expect( + invoke.mock.calls.some( + ([command]) => command === 'replace_local_project_version_resource', + ), + ).toBe(true), + ); + expect(invoke).toHaveBeenCalledWith( + 'replace_local_project_version_resource', + { + input: { + projectPath: PROJECT_PATH, + expectedProjectId: PROJECT_ID, + expectedProjectRevision: EXPECTED_REVISION, + sourceVersionId: SOURCE_VERSION_ID, + sourceResourceId: 'asset-legacy', + replacementResourceId: 'asset-final', + }, + }, + ); + + // 直接替换:重读 manifest 并按新 revision 提交,但**不切版本**(没有新版本可切)。 + await waitFor(() => + expect(onManifestChange).toHaveBeenCalledWith( + PROJECT_PATH, + expect.objectContaining({ projectId: PROJECT_ID }), + expect.objectContaining({ revision: 6, source: 'asset-command' }), + ), + ); + expect(onActiveVersionChange).not.toHaveBeenCalled(); + expect(onPlay).not.toHaveBeenCalled(); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(), + ); + }); + + it('后端拒绝时保留弹窗、显示原因,且不切版本、不重读 manifest', async () => { + const { invoke, onActiveVersionChange, onManifestChange } = + renderReplacementWorkbench({ + replacementWrite: async () => { + throw new Error('resource-replacement-incompatible:分类不同'); + }, + }); + + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + const dialog = await screen.findByRole('dialog', { + name: '选择替换素材', + }); + fireEvent.click( + within(dialog).getByRole('option', { name: '选择替换素材final.png' }), + ); + fireEvent.click( + within(dialog).getByRole('button', { name: '确认选择替换素材' }), + ); + + await waitFor(() => + expect(within(dialog).getByRole('alert').textContent).toBe( + '替换素材不兼容:分类不同', + ), + ); + expect(screen.getByRole('dialog', { name: '选择替换素材' })).not.toBeNull(); + expect(onActiveVersionChange).not.toHaveBeenCalled(); + expect(onManifestChange).not.toHaveBeenCalled(); + expect( + invoke.mock.calls.some( + ([command]) => command === 'get_local_game_manifest', + ), + ).toBe(false); + }); + + it('候选读取失败时不弹空壳弹窗,直接把原因说明白', async () => { + renderReplacementWorkbench({ + replacementCandidates: async () => { + throw new Error('源项目版本不存在:initial-1'); + }, + }); + + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + + await waitFor(() => + expect( + screen.getByText('当前版本已不存在,请刷新项目后重试'), + ).not.toBeNull(), + ); + expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts new file mode 100644 index 000000000..2ac98a05e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts @@ -0,0 +1,290 @@ +/** @vitest-environment jsdom */ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + LocalProjectVersionReplacementCandidate, + ProjectVersionResourceCompatibility, +} from '../src/features/resource-canvas/resourceVersionReplacementModel'; +import { + resourceReplacementAssetHints, + resourceReplacementBlockedReason, + resourceReplacementBlockedReasons, + resourceReplacementPickerAssets, + resourceReplacementWarning, + resourceVersionReplacementErrorMessage, +} from '../src/features/resource-canvas/resourceVersionReplacementModel'; +import { + readVersionResourceReplacementCandidates, + replaceVersionResource, +} from '../src/features/resource-canvas/resourceVersionReplacementTransport'; +import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel'; + +function candidate( + resourceId: string, + compatibility: Partial = {}, + overrides: Partial = {}, +): LocalProjectVersionReplacementCandidate { + const resolved: ProjectVersionResourceCompatibility = { + categoryEqual: true, + subtypeEqual: true, + sizeSpecEqual: true, + ...compatibility, + }; + return { + resourceId, + // 直接替换口径:只有分类与类型参与"能不能选"。 + compatible: resolved.categoryEqual && resolved.subtypeEqual, + compatibility: resolved, + blockedReason: null, + warning: null, + ...overrides, + }; +} + +function manifestWithAssets( + assets: Array<{ + id: string; + kind: string; + mediaType: string; + localPath: string; + }>, +): GameCreationAppManifest { + return { + schemaVersion: 'game-creator-manifest.v1', + projectId: 'project-1', + name: '资源替换模型测试', + goal: null, + tasks: [], + assets: assets.map((asset) => ({ + ...asset, + source: { kind: 'generated' as const }, + })), + }; +} + +describe('资源替换的展示层口径', () => { + it('可选时没有禁用原因,不可选时优先用后端给的原因', () => { + expect(resourceReplacementBlockedReason(candidate('asset-ok'))).toBeNull(); + expect( + resourceReplacementBlockedReason( + candidate( + 'asset-x', + { categoryEqual: false }, + { blockedReason: '分类不同' }, + ), + ), + ).toBe('分类不同'); + }); + + it('后端没给原因时按硬门禁顺序派生(分类 → 类型),尺寸规格不参与禁用', () => { + expect( + resourceReplacementBlockedReason( + candidate('asset-category', { + categoryEqual: false, + subtypeEqual: false, + sizeSpecEqual: false, + }), + ), + ).toBe('分类不同'); + expect( + resourceReplacementBlockedReason( + candidate('asset-subtype', { + subtypeEqual: false, + sizeSpecEqual: false, + }), + ), + ).toBe('类型不同'); + // 尺寸规格不同**不再**产生禁用原因:它是提示,不是门禁。 + expect( + resourceReplacementBlockedReason( + candidate('asset-size', { sizeSpecEqual: false }), + ), + ).toBeNull(); + }); + + it('被标记为不可选但硬门禁都通过时仍给出兜底原因,不静默放行', () => { + expect( + resourceReplacementBlockedReason( + candidate('asset-unknown', {}, { compatible: false }), + ), + ).toBe('替换兼容性未通过'); + }); + + it('尺寸规格差异只做提示,且不覆盖真正的不兼容原因', () => { + expect( + resourceReplacementWarning( + candidate('asset-format', { sizeSpecEqual: false }), + ), + ).toBe('格式与源素材不同'); + expect(resourceReplacementWarning(candidate('asset-ok'))).toBeNull(); + expect( + resourceReplacementWarning( + candidate( + 'asset-blocked', + { categoryEqual: false }, + { compatible: false }, + ), + ), + ).toBeNull(); + }); + + it('禁用原因表只收录不可选候选;提示表只收录可选但有差异的候选', () => { + const candidates = [ + candidate('asset-ok'), + candidate('asset-format', { sizeSpecEqual: false }), + candidate('asset-scene', { categoryEqual: false }), + ]; + expect(resourceReplacementBlockedReasons(candidates)).toEqual({ + 'asset-scene': '分类不同', + }); + expect(resourceReplacementAssetHints(candidates)).toEqual({ + 'asset-format': '格式与源素材不同', + }); + }); + + it('候选映射成弹窗素材:复用资源投影口径、不给 img 喂空 src、缺投影的候选不合成条目', () => { + const manifest = manifestWithAssets([ + { + id: 'asset-image', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + }, + { + id: 'asset-audio', + kind: 'background-music', + mediaType: 'audio/mpeg', + localPath: 'assets/theme.mp3', + }, + ]); + const resources = projectResourcesFromReadModels(manifest, [], []); + const assets = resourceReplacementPickerAssets(resources, [ + candidate('asset-image'), + candidate('asset-audio'), + candidate('asset-missing'), + ]); + + expect(assets.map((asset) => asset.id)).toEqual([ + 'asset-image', + 'asset-audio', + ]); + expect(assets.map((asset) => asset.label)).toEqual([ + 'hero.png', + 'theme.mp3', + ]); + expect(assets.map((asset) => asset.mediaType)).toEqual(['image', 'audio']); + // AGC 没有同步 src:交给弹窗的 renderAssetMedia 渲染类型占位,不能挂破图。 + expect(assets.every((asset) => asset.src === '')).toBe(true); + }); + + it('失败文案逐条可读,未知错误原样透出', () => { + expect( + resourceVersionReplacementErrorMessage( + new Error('project-revision-conflict'), + ), + ).toBe('项目已被其它操作改动,请重试替换'); + expect( + resourceVersionReplacementErrorMessage( + new Error('project-identity-conflict'), + ), + ).toBe('项目身份不一致,请重新打开项目后再试'); + expect( + resourceVersionReplacementErrorMessage( + new Error('resource-replacement-incompatible:分类不同'), + ), + ).toBe('替换素材不兼容:分类不同'); + expect( + resourceVersionReplacementErrorMessage( + new Error('源项目版本不存在:initial-1'), + ), + ).toBe('当前版本已不存在,请刷新项目后重试'); + expect( + resourceVersionReplacementErrorMessage( + new Error('源版本未绑定该素材:initial-1 · asset-1'), + ), + ).toBe('该素材不在当前版本的绑定里,不能替换'); + expect( + resourceVersionReplacementErrorMessage( + new Error('项目资源不存在:asset-x'), + ), + ).toBe('替换素材未登记或已被删除'); + expect( + resourceVersionReplacementErrorMessage(new Error('替换素材与源素材相同')), + ).toBe('替换素材与源素材相同'); + expect( + resourceVersionReplacementErrorMessage( + new Error('项目版本记录写入后不可修改、删除或重排'), + ), + ).toBe('替换被项目版本写入边界拒绝,请刷新项目后重试'); + expect( + resourceVersionReplacementErrorMessage( + new Error('替换素材需要在客户端内执行'), + ), + ).toBe('替换素材需要在客户端内执行'); + expect(resourceVersionReplacementErrorMessage(new Error('磁盘满了'))).toBe( + '磁盘满了', + ); + expect(resourceVersionReplacementErrorMessage(undefined)).toBe( + '替换素材失败', + ); + }); +}); + +describe('资源替换的 IPC 边界', () => { + it('两个命令都只传一个 input 对象,字段名与 Rust 结构体逐字一致', async () => { + const invoke = vi.fn(async () => ({ ok: true })); + window.__TAURI__ = { core: { invoke } }; + + await readVersionResourceReplacementCandidates({ + projectPath: '/tmp/project', + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + }); + expect(invoke).toHaveBeenNthCalledWith( + 1, + 'read_local_project_version_resource_replacement_candidates', + { + input: { + projectPath: '/tmp/project', + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + }, + }, + ); + + await replaceVersionResource({ + projectPath: '/tmp/project', + expectedProjectId: 'project-1', + expectedProjectRevision: 7, + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + replacementResourceId: 'asset-target', + }); + expect(invoke).toHaveBeenNthCalledWith( + 2, + 'replace_local_project_version_resource', + { + input: { + projectPath: '/tmp/project', + expectedProjectId: 'project-1', + expectedProjectRevision: 7, + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + replacementResourceId: 'asset-target', + }, + }, + ); + }); + + it('不在客户端内执行时明确报错,不做静默成功', async () => { + (window as unknown as { __TAURI__?: unknown }).__TAURI__ = undefined; + await expect( + readVersionResourceReplacementCandidates({ + projectPath: '/tmp/project', + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + }), + ).rejects.toThrow('替换素材需要在客户端内执行'); + }); +}); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index ab67f5ea7..95e10902c 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -40,6 +40,8 @@ - 新版本必须记录 `parentVersionId`、替换前后资源身份和创建原因。 - 当前运行中的版本不消费尚未生成的新版本变更。 +> **实现状态(2026-09-11):本节与 §5.3 的 `ProjectVersionResourceReplacement` 作为未来合同保留,当前实现为「直接替换」**——改 manifest 里该版本的绑定指向另一个已登记资源,**不创建下一迭代版本**(用户当日 DDL 口径:「替换这块先做成直接替换」)。因此本节三条今天**暂不实现**:没有 `parentVersionId` 子版本、没有可回溯的替换历史(替换前身份只剩一条 `asset.version_binding.replace` 审计与 manifest 的 `.previous` 副本)。上面第二条的"可运行版本不可变"与最后一条"运行中的版本不消费尚未生成的新版本变更"仍然成立。直接替换的完整口径见 §5.3 与 §7.8;需要恢复版本级替换时,本节与 §5.3 的 DTO 就是合同。 + ### 3.3 资源布局 - “按依赖”和“按类型”分别保存画布位置。 @@ -187,10 +189,10 @@ idle -> focused(document|art|audio|version) -> idle - 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。聊天侧 `/read` 回执中的文件正文必须作为代码块渲染为 `
`,以便用户审阅源码字面量但不执行其中的 HTML;Markdown 渲染使用 `react-markdown` 的 `skipHtml`,依赖库对代码 span / fenced code 的文本转义;不得在整段 Markdown 上预转义 HTML,否则会把代码中的 `` 双重转义为字面量 `<tag>`。
 - 美术:PNG、JPEG、WEBP、GIF、SVG、AVIF、BMP、MP4、WebM、MOV 只在资源卡本体中按既有受控读取、文件签名与解码门禁展示;中央详情不重复加载或放大图片 / 视频本体。SVG 继续拒绝脚本、事件处理器、外部资源引用和实体声明。
 - 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
-- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片。
+- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换(2026-09-11 实施)不在版本聚焦态提供,入口在资源卡选中工具条:选中被**当前版本**绑定的素材后替换成另一已登记素材,落盘为「改该版本的绑定、不建新版本」,详见 §5.3 与 §7.8。
 - mentor 最新决定:资源聚焦不提供工具栏,也不提供工具侧边栏。
 - 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
-- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源编辑只允许追加继承源绑定并记录提示词的子版本,不允许原地替换或修改源版本。
+- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源编辑只允许追加继承源绑定并记录提示词的子版本,不允许原地替换或修改源版本;资源替换同样不改既有版本,而是追加一条 `resource-replacement` 子版本(§5.3)。
 - 资源聚焦不提供通用工具栏或工具侧边栏;图片聚焦态允许一个明确的“精修资源”业务动作进入素材创作无限画布,该动作不是在聚焦容器中内嵌编辑器或恢复通用工具栏。
 - 点击资源后,中央主视窗从 `resource-overview.list` 切换为 `resource-overview.focused.document / art / audio / version`,左侧平台导航、右侧 Supervisor 对话和底部 Agent 状态栏保持原位;聚焦容器以路径、类型、来源任务、依赖层级、同类型上下游和版本字段为首屏主体,不使用页面级浮层或可拖动标题栏。文档正文与按意图加载的音频控制位于元数据之后。
 - 焦点转换以稳定资源 ID 为准。只有从资源列表进入详情或从一个资源 ID 切换到另一个 ID 时聚焦详情 region;同一资源 ID 因 manifest 更新而重新投影时,不得抢走详情内音频 / 视频控件、文档链接或收起按钮的当前焦点。
@@ -394,11 +396,26 @@ type ProjectVersionResourceReplacement = {
 };
 ```
 
+> `ProjectResourceDescriptor.category` 是**历史形状**(旧四分类轴),已被本节上方 2026-09-10 / 2026-09-11 的六分类收口取代;实现以 manifest `assets[].category` 的六分类与读时自愈口径为准,本 DTO 只保留作名词参照。`width / height / durationMs` 也只在该历史形状里存在,manifest 资产表今天并没有这些字段(见下方 `sizeSpecEqual` 的降级声明)。
+
 三项兼容性必须同时为 true 才能创建下一版本。
 
+> **实现状态(2026-09-11):上面这两段(`ProjectVersionResourceReplacement` 与"三项必须同时为 true 才能创建下一版本")作为未来合同保留;当前实现是「直接替换」**——改 manifest 里该版本的绑定指向另一个已登记资源,**不创建下一版本**,因此"才能创建下一版本"这一门槛今天不成立。今天的准入与提示见下方。
+
+替换实现口径(2026-09-11,**直接替换**):
+
+- **落盘 = 改该版本的绑定**:不创建新版本、不删除版本、不动资源文件、不建文件副本;被改的是源版本自己的 `resourceBindings`,其余字段与其余版本整条不动,版本数量不变。写入走 §5.4 存储边界里的"版本绑定改写放行"(见该节的第二个例外)。
+- **绑定改写 = 源素材从该版本的绑定集合里消失 + 保证替换素材在集合里**:恒等绑定口径下 `resourceBindings` 是"该版本使用的素材集合"而不是槽位表,所以替换素材是源版本创建之后才登记时按源素材原来的位置插回(顺序稳定),替换素材早已登记时只做摘除 —— 不能把源素材那条槽位改写成替换素材,那会撞「资源槽位重复」。
+- **准入与提示**(后端权威,前端只呈现,不重算):
+  - **硬门禁**只有 `categoryEqual` 与 `subtypeEqual`:不满足则拒绝并说明哪一项不等,界面不得出现成功态。`categoryEqual` 用**读时自愈**口径(见本节 2026-09-11 收口),Rust 侧与 `packages/shared` 逐分支一致的实现是 `game_creation_app_asset_category_with_read_time_healing`;`subtypeEqual` 比较 canonical `kind`。
+  - `sizeSpecEqual` **只作提示、不再拒绝**(提示文案「格式与源素材不同」):它的完整判据今天不存在 —— manifest 资产表没有 `width / height / durationMs`,现役写入侧(上传、派生、画板回传、生成回流)几乎全部写 `imageSequenceFrames: None`,实际只等于"媒体格式相等";用不完整到会误拒 `png ↔ webp` 的判据去挡替换是错的。要把它变成硬判据,必须先给 manifest asset 增尺寸字段并在写入侧回填(跨端契约变更),并把「缺失即未知」的口径写进契约。
+- **失败即拒绝**:硬门禁任一项不等、源版本不存在、源版本未绑定该素材、替换素材未登记、替换素材与源素材相同、`expectedProjectId` / `expectedProjectRevision` CAS 冲突,都必须在写入前拒绝并说明原因;拒绝时 manifest、`versions` 与项目 revision 都不变,投影里不得出现新的版本卡或新的"当前使用"高亮。
+- **落盘与留痕**:改绑定属于 `versions` 变化,写入后推进一次 `projectRevision`;并追加一条 `asset.version_binding.replace` 审计(`versionId / sourceResourceId / replacementResourceId / projectRevision`)。**没有可回溯的替换历史**:替换前的身份只有这条审计与 manifest 的 `.previous` 恢复副本。
+- **入口与可见效果**:入口在资源卡选中工具条,只对「manifest 资产 + 被当前版本绑定」的素材放行;候选弹窗列出全部候选,命中硬门禁的项渲染但禁用并给出原因,只有尺寸规格差异的项仍可选但带提示。成功后**不切换版本**(没有新版本可切),只重读 manifest;可见变化是资源卡"当前使用"高亮移到替换素材、`@` 面板"当前版本素材"更新。按 §7.4 的口径,改绑定不做运行时资源重映射,运行画面本身不会因改绑定而变化;也不自动重载 / 重启运行中的预览(§3.2 末条)。
+
 ### 5.4 游戏迭代版本(P1)
 
-阶段六实现状态(2026-08-13 更新):正式版本业务真相扩展在本地项目 `.agent/manifest.json` 的可选 `versions` 字段中;旧项目字段缺失时等价于空列表,不根据 checkpoint、布局 sidecar、静态检查、失败试玩或单独的 `game-creator-project-revision.v1` 自动伪造版本。版本数组只允许追加,已有记录不得删除、重排或修改;首轮没有版本创建按钮。自主首板只有在当前 revision 的 `preview.validate` 已成功形成持久试玩回执后,才幂等追加首条 `initial` 版本,并绑定当时 manifest 中全部已登记资源;同一完成态恢复不得重复创建。已有正式版本时,后续试玩通过不自动追加版本,仍由明确的资源派生事务创建子版本。
+阶段六实现状态(2026-08-13 更新):正式版本业务真相扩展在本地项目 `.agent/manifest.json` 的可选 `versions` 字段中;旧项目字段缺失时等价于空列表,不根据 checkpoint、布局 sidecar、静态检查、失败试玩或单独的 `game-creator-project-revision.v1` 自动伪造版本。版本数组只允许追加,已有记录不得删除、重排或修改(**两个例外见下方存储边界两条**);首轮没有版本创建按钮。自主首板只有在当前 revision 的 `preview.validate` 已成功形成持久试玩回执后,才幂等追加首条 `initial` 版本,并绑定当时 manifest 中全部已登记资源;同一完成态恢复不得重复创建。已有正式版本时,后续试玩通过不自动追加版本,仍由明确的资源派生事务创建子版本。
 
 ```ts
 type GameIterationVersion = {
@@ -421,6 +438,7 @@ type GameCreationAppManifest = {
 - 同一版本内 `slotId` 唯一;`resourceId` 固定保存 manifest asset ID,不保存资源卡显示名称、External Editor resource ID、路径或布局 ID。历史资源已不在当前 manifest 时仍保留原绑定,但界面不为其合成资源卡。
 - Tauri manifest 存储边界在每次写入前校验完整版本图,并与磁盘中的旧 `versions` 前缀逐项比较;只允许追加新记录,已有记录被修改、删除或重排时写入失败且原文件保持不变。
 - (2026-09-10 补充)上述"只允许追加"有且只有一个例外:**用户在删除素材时显式确认"把相关游戏版本一并删除"**。该路径下,只有引用被删素材的那些版本允许消失,其余既有版本仍必须原样、原顺序保留,新增版本仍只能追加在末尾;放行集合按**写入前的 manifest** 求值,且被放行的版本不得出现在追加段里(防止"删除后重排"绕过校验)。除该路径外,任何版本删除、修改、重排仍然失败关闭。
+- (2026-09-11 补充,当前状态)"只允许追加"现在有**两个**例外,第二个比第一个更窄:**直接替换资源时,显式放行列出的版本允许改写自己的 `resourceBindings`**(`mutate_manifest_allowing_version_binding_rewrites`)。它的不变式是:不增不删(候选与磁盘的版本数量必须相等)、不重排(版本 ID 序列逐项相同)、只有放行清单里的版本允许 `resourceBindings` 不同、其余字段(`versionId / parentVersionId / projectRevision / createdReason / createdAt / editPrompt`)逐字段相等、未放行版本整条相等、放行集合按**写入前的 manifest** 求值、与"放行删除"集合**互斥**(同一版本 ID 不得既被放行删除又被放行改写绑定)。这条通道只服务"改绑定",任何版本的新增、删除、重排仍然失败关闭,删除放行通道也不因此获得改写能力。
 - 版本卡标题由稳定追加序号生成,卡片与聚焦态展示 `versionId / projectRevision / createdReason / parentVersionId`;聚焦态额外展示直接子版本和全部 slot 绑定。点击版本卡只高亮当前投影中唯一匹配 `asset:` 的资源卡,不修改版本或资源。
 
 ### 5.5 测试切片与数值参数(P2)
@@ -483,7 +501,7 @@ type ProjectAgentMudPointAttribution = {
 
 - 已实施依赖/类型两套坐标持久化、首次默认不重叠布局、历史坐标跨重启恢复、自动协调 CAS 冲突处理与资源卡手动拖动。
 - 资源关系线在布局持久化验收通过后单独实施,不与本切片捆绑伪造完成。
-- 已实施正式版本不可变模型、版本卡、父子关系与引用资源高亮;“编辑资源”可追加继承源绑定并记录提示词的子版本,资源直接替换、运行版本切换和兼容性迁移仍待后续切片。
+- 已实施正式版本不可变模型、版本卡、父子关系与引用资源高亮;“编辑资源”可追加继承源绑定并记录提示词的子版本;资源替换(2026-09-11)已按**直接替换**口径实施为「改该版本的绑定、不建新版本」(§5.3 / §7.8),入口在资源卡选中工具条;运行版本切换(C7)已完成;§3.2 的版本级替换与兼容性迁移仍待后续切片。
 - 素材创作无限画布阶段一按权威专题一次交付图片导入、编辑、生成、导出、草稿恢复、正式本地回写、即时投影和焦点竞态闭环。
 - 高级抠图、图集、角色动画、视频时间线编辑和音频波形级编辑按后续切片实施;当前视频走源引用派生,音频只做语义重制。
 
@@ -540,7 +558,7 @@ type ProjectAgentMudPointAttribution = {
 1. manifest 缺少 `versions` 时旧项目正常打开且不显示伪造版本;存在合法记录时,固定“项目版本”分区按追加顺序显示稳定版本卡。
 2. 根版本、父版本和直接子版本关系在卡片或聚焦态可见;悬空父版本、自引用、重复 ID、非递增修订、倒退时间、重复 slot 和超限数字均失败关闭。
 3. 点击版本卡后,当前 manifest 中仍存在的绑定资产卡被高亮;历史已删除资产只在版本详情保留 ID,不创建幽灵卡,也不把 External Editor resource ID 猜成 manifest asset ID。
-4. 版本聚焦态展示身份、修订、创建原因、父子关系、创建时间和 slot 绑定;“编辑资源”只追加继承源绑定并记录提示词的子版本,不提供原地替换、切换、回滚或运行按钮。
+4. 版本聚焦态展示身份、修订、创建原因、父子关系、创建时间和 slot 绑定;“编辑资源”只追加继承源绑定并记录提示词的子版本,不提供原地替换、切换、回滚或运行按钮。资源替换**也不在版本聚焦态提供**:它的入口是资源卡选中工具条(§5.3 / §7.8),本条限制的是版本聚焦态这个容器,不限制资源卡入口。
 5. 任意现有 manifest 写入只能保留磁盘版本前缀并追加新记录;存储边界以跨进程专用锁串行覆盖旧状态读取、前缀校验、安装和回读,修改、删除、重排或并发旧快照覆盖已有版本时写入失败。
 6. 版本选择和高亮不写 manifest、布局 sidecar 或 project revision;dependency / type 两种布局都可显示绑定高亮,既有依赖关系 SVG 语义不变。
 
@@ -592,6 +610,16 @@ type ProjectAgentMudPointAttribution = {
 8. 右侧正式钱包入口在普通工作台和 UI Editor 子路由都始终可见、键盘可达并能打开余额、充值与使用详情;路由切换不得使入口消失或失去交互。
 9. “资源依赖 / 资源类型”以连通分段按钮呈现,点击与键盘操作均只保留一个选中项;使用 Tab 定位和键盘切换时焦点指示清晰、完整,不被容器边界或 `overflow` 裁切。
 
+### 7.8 P1 资源替换验收(直接替换)
+
+1. 入口只对「manifest 资产 + 被当前版本绑定」的素材渲染;未绑定素材(版本创建后才登记、或不属于当前版本绑定集合)一律不出现入口,也不出现"点了没反应"的假按钮。
+2. 候选弹窗列出后端返回的全部候选:命中硬门禁(分类 / 类型)的项**渲染但禁用**并显示不等维度,只有尺寸规格差异的项**仍可选**并带「格式与源素材不同」的提示;不得隐藏不可选候选,不得合成非候选素材,不得在前端重算判据。
+3. 写入后的 manifest:**版本数量不变**(不新增、不删除),被替换的那个版本除 `resourceBindings` 外逐字段不变(`versionId` / `parentVersionId` / `projectRevision` / `createdReason` / `createdAt` / `editPrompt`),其余版本整条不变;项目 revision 推进一格。
+4. 绑定改写 = 源素材从该版本的绑定集合里消失 + 保证替换素材在集合里;不得出现重复 `slotId`。替换素材在源版本创建后才登记时,差异恰好"一减一增"且顺序稳定;替换素材早已登记时只少掉源素材那一条。
+5. 硬门禁任一为 false、源版本不存在、源版本未绑定该素材、替换素材未登记、替换素材与源素材相同、`expectedProjectId` / `expectedProjectRevision` 冲突,都必须在写入前拒绝;拒绝时 manifest、`versions` 与项目 revision 都不变,投影里不得出现新的"当前使用"高亮或版本卡变化。
+6. 成功后重读 manifest 并**不切换版本**(没有新版本可切);**不**自动重载或重启运行中的预览(§3.2 末条),也不做运行时资源重映射。可见变化只有:资源卡"当前使用"高亮移到替换素材、`@` 面板"当前版本素材"更新。
+7. 替换成功后追加一条 `asset.version_binding.replace` 审计(`versionId / sourceResourceId / replacementResourceId / projectRevision`);**替换历史不可回溯**——替换前身份只有这条审计与 manifest 的 `.previous` 副本,不得声称能查到"某版本历史上换过什么"。
+
 ## 8. 非目标
 
 - 当前不开放“新增资源”产品入口。图片精修候选保持私有且不污染资源总览;“设为最终图”只允许保持原 asset ID、以新正式文件和事务方式切换 manifest 指针,不原地覆写旧文件。其他资源编辑继续追加派生 asset 或子版本。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index bbc0ce47c..90dd71f64 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -8494,3 +8494,28 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
 - 原因:预览起停不是源码变更,不能靠"给它也推一次 revision"来消除冲突——那会让运行时的验证凭证(`expected_revision` / `verified_revision` / `failed_playtest_revision`)凭空漂移,把自主构建流程拖进无谓的重新验证。正确的修法是把判据面收回到契约所说的对象,而不是让簿记写入承担源码语义。
 - 验证:`projectResourceLiveUpdateModel.test.ts` 新增「同 revision 只有 `preview` 变化必须被接受」与「同 revision 资产变化必须仍被拒收」两条;变异回整份 JSON 指纹后前者以 `expected 'revision-conflict' to be 'accepted'` 失败。定向:模型 17 passed、workspaceLauncherManifestMerge 5 passed、appSurface 413 passed、typecheck exit 0、check:encoding 4399 files passed。
 - 影响范围:`apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts`、`apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts`、`pitfalls.md` 本条。
+## 2026-09-11 AGC 资源替换按 PRD 恢复为「版本级替换」:改绑定落在追加的新版本上
+
+- 背景:PRD §3.2(`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md:36-41`)要求「替换版本引用资源时创建下一迭代版本,不原地修改既有版本」,并要求新版本记录 `parentVersionId`、替换前后资源身份与创建原因;§5.3(:356-387)给出 `ProjectVersionResourceReplacement` 与「三项兼容性必须同时为 true 才能创建下一版本」。本文件 2026-09-10 那条(`decision-log.md:8263`)与 Issue #309 的 C1 决策 / 贯穿性决策 6 / 验收总纲当时写的是相反口径:「改 manifest 绑定……**不创建新版本**」「替换功能(候选素材 / 替换关系 / 替换队列 / Agent 审核 / 单点替换)整条取消,不实现」。用户裁决:**按 PRD 来**,实际机制仍是「改 manifest 绑定」。
+- 决策:资源替换 = **改绑定 + 追加下一迭代版本**。新命令 `replace_local_project_version_resource`(写,`asset.register`)在持项目写锁与 `expectedProjectId + expectedProjectRevision` CAS 下,一次 `mutate_manifest_at` 写入里追加 `createdReason='resource-replacement'` 的子版本(`parentVersionId` 指向被替换的源版本),子版本绑定 = 源版本绑定**去掉源素材**并保证**替换素材在集合里**;只读命令 `read_local_project_version_replacement_candidates`(`asset.list`)返回候选与后端权威的三项兼容性。既有版本记录一个字节不改,**继续走默认空放行集合**,不新增/放宽 `mutate_manifest_at_allowing_version_removals`。
+- 恒等绑定口径的硬约束(实测得出):每个版本的 `resourceBindings` 是「该版本使用的素材集合」而不是槽位表,替换素材若在源版本创建时就已登记,它本来就在集合里,因此**不能**把源素材那条槽位改写成替换素材(会撞「资源槽位重复」);正确落盘是「源素材从集合里消失 + 替换素材在集合里」,替换素材是后来才登记时按源素材原位置插回。
+- 三项兼容性判据:`categoryEqual` 用 PRD §5.3 的**读时自愈**口径(为此在 `server-rs/crates/shared-contracts` 新增 `game_creation_app_asset_category_with_read_time_healing`,与 `packages/shared` 的 `gameCreationAppAssetCategory` 逐分支一致并配用例锁定——此前 Rust 反序列化只有「缺失/非法 → 按 kind 派生」,没有自愈,两侧口径并不一致);`subtypeEqual` 比较 canonical `kind`;`sizeSpecEqual` 比较规范化媒体格式 + 已知帧宽高 + 已知时长,任一方有事实的维度必须相等。
+- 已知降级(刻意接受,不得当成完整实现):manifest 资产表没有 `width / height / durationMs`,现役写入侧几乎全写 `imageSequenceFrames: None`,所以 `sizeSpecEqual` 在真实数据上退化为**媒体格式相等**(`png ↔ webp` 会被拒)。要支持跨图片格式替换,必须先给 manifest asset 增尺寸字段并在写入侧回填(跨端契约变更)。
+- 替换前后资源身份:按 PRD §5.4 的版本字段表口径**用推导记录**,不新增 manifest / 跨端契约字段(`父 − 子 = {源素材}`、`子 − 父 = {替换素材}`,配对由 `parentVersionId` + `createdReason` 确定)。已知限制:替换素材在源版本创建时就已登记时 `子 − 父` 为空集,版本记录无法单独反推配对;要无歧义持久化配对须先给 `GameIterationVersion` 增字段。
+- 边界:C6 的候选素材 / 替换关系图 / 替换队列 / Agent 审核 / 批量提交**仍然不做**(#309 该部分不变);§3.2 末条「当前运行中的版本不消费尚未生成的新版本变更」保持——成功后只切记录层当前版本,不自动重载/重启预览,也不做运行时资源重映射。入口只在资源卡选中工具条、且只对「manifest 资产 + 被当前版本绑定」的素材渲染;候选弹窗复用美术画布的 `ImageCanvasProjectAssetPickerDialog`(AGC 首次使用),以可选 prop 扩展且默认值保持网页端行为逐字不变。
+- 影响范围:`server-rs/crates/shared-contracts/src/game_creation_app.rs`、`src-tauri/src/project/version_resource_replacement.rs`(新增)、`commands.rs`、`main.rs`、`tests/version_resource_replacement.rs`(新增)、`src/features/resource-canvas/resourceVersionReplacement{Model,Transport}.ts`(新增)、`view/project-development/index.tsx`、`src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx`、`apps/ai-game-creator-shell/src/styles.css`。
+- 验证方式:Rust 定向 8 条 + `shared-contracts` 20 条;变异验证三条(去掉媒体格式维度 → 2 条转红;绕开读时自愈 → 自愈用例转红;改成原地改既有版本 → 4 条转红并被「项目版本记录写入后不可修改、删除或重排」拦下,证明只追加守卫真的在挡)。前端新增 12 条,变异验证两条(放宽入口判据 → 「不给假按钮」转红;失败路径静默关弹窗 → 「保留弹窗显示原因」转红)。AGC 全量 1231 passed / 4 skipped / 0 failed;共享美术画布组件 1385 passed;typecheck / `cargo check --locked --all-targets` / `check:encoding` / `git diff --check` 全绿。跨端契约、SpacetimeDB schema、manifest 结构与布局 sidecar schema 均未改动。
+- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`(§3.2 保持,§5.3 补实现口径与降级声明,§7.8 新增替换验收)、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(新增同章节)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(§7.3 第 1 条已按本次口径改写)、Issue #309。
+
+## 2026-09-11 AGC 资源替换改成「直接替换」:只改该版本的绑定,不建新版本
+
+- 背景:同日上午按 PRD §3.2 / §5.3 落地的「版本级替换(改绑定 + 追加下一迭代版本)」,当天下午被用户改口径:「**替换这块先做成直接替换**」(同日 DDL:替换先做,运行画面点选、切图集像素规整先不做)。直接替换 = 改 manifest 里该版本的绑定指向另一个已登记资源,**不追加新版本**,也就是 #309 原先那条「更换资源 = 改 manifest 绑定」的轻形态。
+- 决策(实现):删除版本级路径(按「四不写」:不建 `replace-{revision}` 版本、不写 `parentVersionId`、不写 `createdReason=resource-replacement`、不留兼容分支)。直接替换走**新增的第二条、更窄的版本放行通道** `mutate_manifest_allowing_version_binding_rewrites`:不增不删(版本数量必须相等)、不重排(版本 ID 序列逐项相同)、只有显式放行列出的版本允许 `resourceBindings` 不同、其余字段(`versionId / parentVersionId / projectRevision / createdReason / createdAt / editPrompt`)逐字段相等、未放行版本整条相等、放行集合取自**写入前** manifest、与删除放行集合**互斥**。既有 `validate_version_records_are_append_only` 与既有删除放行通道的语义一行未改;唯一结构性改动是把写盘公共体抽成带校验器参数的内部函数(`write_manifest_locked` 变成一行委托),装盘 / 回读 / 原子替换仍只有一份实现。
+- 恒等绑定口径的硬约束(实测得出,两个形态共用):每个版本的 `resourceBindings` 是「该版本使用的素材集合」而不是槽位表,所以绑定改写 = **源素材从集合里消失 + 保证替换素材在集合里**;不能把源素材那条槽位改写成替换素材(替换素材若早已登记就会撞「资源槽位重复」)。
+- 准入与提示:硬门禁只有 `categoryEqual` 与 `subtypeEqual`(拒绝并说明哪一项不等);`sizeSpecEqual` **降级为提示**(「格式与源素材不同」)不再拒绝。理由:它的完整判据今天不存在(manifest 资产表没有 `width / height / durationMs`,现役写入侧几乎全写 `imageSequenceFrames: None`,实际只等于"媒体格式相等"),硬拦会误拒 `png ↔ webp` 这类直接替换里最常见的需求。要变成硬判据须先给 manifest asset 增尺寸字段并在写入侧回填(跨端契约变更)。
+- 存储与留痕:改绑定属于 `versions` 变化,因此写入后**推进一次 project revision**(跨面快照门禁要求 revision 前进),并追加一条 `asset.version_binding.replace` 审计(复用既有 `append_agent_db_record`,字段 `versionId / sourceResourceId / replacementResourceId / projectRevision`)。审计写失败会报错但不回滚,与 `asset.register` 同口径。
+- **代价(用户已确认接受,记账备查):直接替换没有可回溯的替换历史。** 上一轮版本级形态里"父版本绑定"就是那份历史;现在替换前的身份只存在于 ① 那条审计记录 ② manifest 的 `.previous` 恢复副本(只保留上一次写入)。需要"某个版本历史上被换过几次、换成过什么"时必须另立切片(PRD §3.2 / §5.3 保留为未来合同正是为此)。
+- 文档口径:PRD §3.2 的两条与 §5.3 的 `ProjectVersionResourceReplacement` / "三项兼容性必须同时为 true 才能创建下一版本"**保留为未来合同**并就地标注「当前实现为直接替换(2026-09-11),版本级替换暂缓」;PRD 的**存储边界**("只允许追加…有且只有一个例外")改写成当前状态——现在是**两个例外**(删除素材时连带删版本 / 显式放行版本的绑定改写),因为它是"现在写入校验行为"的描述,留未来式会误导。
+- 影响范围:`src-tauri/src/project/manifest.rs`、`src-tauri/src/project/manifest/version_binding_rewrite_tests.rs`(新增)、`src-tauri/src/project/version_resource_replacement.rs`、`src-tauri/src/tests/version_resource_replacement.rs`、`src/features/resource-canvas/resourceVersionReplacement{Model,Transport}.ts`、`view/project-development/index.tsx`、`src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx`、`apps/ai-game-creator-shell/src/styles.css`。
+- 验证方式:放行通道定向 7 条 + 替换定向 8 条 + `shared-contracts` 20 条;**变异验证四条**(去掉"未放行版本整条相等"→两周转红;放行集合改成整个版本数组→"未放行版本"转红;去掉长度检查→"不增不删"转红;准入删掉 category→硬门禁与候选两条转红,均已实测并还原)。前端 13 条(模型 9 + 真链路 4)。门禁:AGC 全量 1231 passed / 4 skipped / 0 failed、共享美术画布组件 1385 passed、`ai-game-creator-shell:typecheck`(含 check-config)、`cargo check --locked --all-targets`、`check:encoding`、`git diff --check` 全绿。
+- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`(§3.2 / §5.3 / §5.4 存储边界 / §7.8 验收)、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(同章节)、`docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md`、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`、Issue #309。
diff --git a/docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md b/docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md
index ded83bb73..86359928e 100644
--- a/docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md
+++ b/docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md
@@ -25,7 +25,7 @@
 - 文本、SVG 与 Agent 回执在 Provider 调用前必须先持久化 request-issued;成功响应必须先原子安装到与原 operation、请求指纹和内容摘要绑定的私有 durable handoff,再做 envelope 解析、格式校验和 staging。issued 后缺少可信 handoff 只能对账;handoff 已存在且校验通过时恢复只消费该正文,两种情况都禁止再次调用 Provider。
 - 普通客户端视频使用 `POST /api/editor/videos/generations`。有稳定远端引用时直接作为 `referenceVideoSrcs`,只有本地文件时先走 `/api/assets/direct-upload-tickets`、OSS 表单上传和 `/api/assets/objects/confirm`,再提交同一逻辑生成;结果必须下载到新的本地文件并登记远端稳定身份。
 - 普通客户端音效和背景音乐分别使用 `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`。当前接口没有源音频引用字段,因此产品语义固定为"基于原资源语义的派生重制",界面不得描述为对源波形的裁剪、变声或局部修改;结果仍必须引用源资源身份并保留原音频。
-- 项目版本编辑固定追加 `parentVersionId` 指向源版本的子版本,继承源版本资源绑定并记录本轮编辑提示;已有版本数组元素不可修改、删除或重排。
+- 项目版本编辑固定追加 `parentVersionId` 指向源版本的子版本,继承源版本资源绑定并记录本轮编辑提示;已有版本数组元素不可修改、删除或重排(两个例外见 PRD §5.4 的存储边界两条:删除素材时连带删版本、资源直接替换时改写该版本的 `resourceBindings`)。
 - 角色动画派生结果的 manifest `kind` 固定写 `character-animation`,`source.generationKind` 同步保留 `character-animation`,媒体仍以预览视频加正式序列帧登记。图片编辑等普通派生继续继承源资源语义 kind;后续如需改写为 canonical kind,只能在读取投影或显式迁移中完成,不重写历史 manifest。
 - 图片编辑、视频、音效和背景音乐请求统一在 `generationInputs.source` 写入专用消费身份 `game-creator-resource-editor`;普通内部路由和高级 External 路由都必须实际读取并传递同一稳定 `Idempotency-Key`。队列完成态只向该消费身份返回经过裁剪的稳定 `objectKey / resource / asset` 引用和必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。
 
diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
index b3a3fded0..29e16979a 100644
--- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
+++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
@@ -638,7 +638,7 @@ game-project/
 - 五栏目页签的未读红点由 `ProjectDevelopmentView` 在会话内比较各栏目稳定资源 ID 派生:首次快照只建立基线,只有非当前栏目后续出现新 ID 才加入未读集合;所有栏目切换路径统一以当前栏目变化清除对应未读,项目 scope 变化时重建基线并清空。该状态以 `projectPath + projectId` 隔离,不进入 manifest、布局 sidecar、localStorage、sessionStorage 或后端契约。
 - type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序。manifest 资产的 subtype 使用 `asset.kind`,任务产物、导入附件和 Agent 文本成果使用稳定的来源 fallback;subtype 必须进入资源协调签名,不能因 MIME 相同而退化成按名称混排。
 - 自动协调保存失败时保留当前会话布局;CAS 冲突载入对方最新布局,需要继续协调时最多追加两次重试,持续跨窗口竞争时停止自旋。用户提示只说明“布局已在其他窗口更新”,不要求重新拖动。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。
-- 本布局持久化切片不包含资源关系线、资源替换、聚焦态持久化、分区高度、分区内容倍率、整个画布平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。资源关系线、当前会话内中央聚焦、分区独立高度与内容倍率已在后续独立前端切片接入,都不改变本段 sidecar 合同;其余 P1 能力继续独立实施。
+- 本布局持久化切片不包含资源关系线、资源替换、聚焦态持久化、分区高度、分区内容倍率、整个画布平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。资源关系线、当前会话内中央聚焦、分区独立高度与内容倍率已在后续独立前端切片接入,都不改变本段 sidecar 合同;其余 P1 能力继续独立实施。**资源替换已在 2026-09-11 按「直接替换」口径独立实施**(见本文末「资源替换」一节),同样不改布局 sidecar 合同。
 - 本资源总览布局 sidecar 不包含资源关系线、资源替换、聚焦态持久化、资源总览缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。资源关系线与当前会话内中央聚焦已在后续独立前端切片接入;素材创作 viewport 和图层使用独立草稿 schema,不能写入 `game-creator-resource-layout.v1`。
 
 历史实施顺序已完成 TypeScript / Rust DTO、Tauri sidecar/CAS、前端纯模型与持久 Hook。二维手动拖动接线现已暂缓;重新开放前必须先更新 PRD 与验收合同。任何后续步骤不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。
@@ -666,7 +666,7 @@ game-project/
 
 2026-08-03 阶段六:正式迭代版本直接扩展本地 `.agent/manifest.json`,不新增 checkpoint / layout sidecar / SpacetimeDB 平行业务真相。共享 Rust / TypeScript 合同新增可选 `versions: GameIterationVersion[]`;旧项目缺失字段时只读为空,不回填。Rust 在 manifest 读写边界校验版本唯一性、父先于子、根/原因一致、父子修订与时间单调、slot 唯一和 JavaScript 安全整数,并在覆盖已有 manifest 前要求磁盘版本数组是新数组的逐项相等前缀,从存储边界保证历史记录不可修改、删除或重排。2026-08-13 起,自主首板在当前 revision 的 `preview.validate` 成功结果和持久试玩回执均落盘后,幂等追加唯一首条 `initial` 版本,并以稳定 `asset:` 槽位绑定当时全部已登记资源;失败试玩、静态 smoke、checkpoint 和普通预览状态不得触发版本创建,恢复重放和已有版本项目也不得重复追加。
 
-工作台资源投影只从 `manifest.versions` 构建版本卡,按数组追加顺序生成稳定“版本 N”标题;不再接收前端独立 `projectVersions` 注入。`resourceBindings.resourceId` 只解释为 manifest asset ID,并映射到现有 `asset:` 卡片。选中版本后在 dependency / type 两种布局中高亮当前仍存在的绑定资产;缺失历史资产只留在版本聚焦详情,不能合成幽灵卡或猜测 External Editor resource ID。版本聚焦复用中央只读容器,展示身份、修订、原因、父版本、直接子版本、创建时间与 slot 绑定。本阶段不提供版本创建、替换、切换、回滚、测试切片或运行态消费入口。
+工作台资源投影只从 `manifest.versions` 构建版本卡,按数组追加顺序生成稳定“版本 N”标题;不再接收前端独立 `projectVersions` 注入。`resourceBindings.resourceId` 只解释为 manifest asset ID,并映射到现有 `asset:` 卡片。选中版本后在 dependency / type 两种布局中高亮当前仍存在的绑定资产;缺失历史资产只留在版本聚焦详情,不能合成幽灵卡或猜测 External Editor resource ID。版本聚焦复用中央只读容器,展示身份、修订、原因、父版本、直接子版本、创建时间与 slot 绑定。**本阶段**不提供版本创建、替换、切换、回滚、测试切片或运行态消费入口;其中**运行版本切换、资源创建与资源替换**已由 2026-09-10 / 2026-09-11 的后续切片补齐(替换按「直接替换」口径,见本文末一节),版本聚焦态本身仍不提供创建、替换、切换、回滚按钮。
 
 历史命令式 drag preview 句柄与局部连接索引可以保留,但项目工作台不再向资源卡传入该入口。拖动热路径、4096 张真实卡片拖动重渲染和 Chromium p95 门槛统一暂缓;当前回归只要求 Pointer Move 不改变卡片坐标、SVG path 或布局 revision。`ResizeObserver` 仍保持单图层单实例,任何实时 DOM 几何都不得通过 Tauri IPC 往返 Rust。
 
@@ -1340,3 +1340,15 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
 
 - AGC 前端请求客户端配套后端的鉴权 API(包括 `/api/llm/models`)收到 `401` 时,共享进行中的 refresh 请求;确认当前用户并安装 Rust / Runner 会话后,用新 access token 最多重试原请求一次。`403` 权限拒绝不触发续期;续期失败保留原鉴权错误,账号切换或登出后不重发旧请求。
 - DirectProject 的 Rust/app-server 对话调用返回鉴权失效时,前端先刷新客户端平台会话并重新提交同一 `clientTurnId`;平台会话代次变化后由 app-server pool 使用新 access token 建立连接,避免长时间运行后必须重新登录。
+## 2026-09-11 AGC 资源工作台 V3:资源替换(**直接替换**:只改该版本的绑定,不建新版本)
+
+- **口径**:用户当日 DDL 口径「替换这块先做成直接替换」→ 改 `.agent/manifest.json` 里该版本的绑定指向另一个已登记资源:**不创建新版本、不删除版本、不动资源文件、不建文件副本**。PRD §3.2 的「创建下一迭代版本」与 §5.3 的 `ProjectVersionResourceReplacement` /「三项兼容性必须同时为 true 才能创建下一版本」**保留为未来合同**,今天不实现;Issue #309 的 C6(候选素材 / 替换关系 / 替换队列 / Agent 审核 / 批量提交)仍然不做。
+- **写入通道(新增的第二条、也是更窄的版本放行口)**:`project/manifest.rs` 的 `mutate_manifest_allowing_version_binding_rewrites` + `validate_version_records_allow_binding_rewrites`。不变式:**不增不删**(候选与磁盘版本数量必须相等)、**不重排**(版本 ID 序列逐项相同)、**只有放行清单里的版本**允许 `resourceBindings` 不同、其余字段(`versionId / parentVersionId / projectRevision / createdReason / createdAt / editPrompt`)逐字段相等、**未放行版本整条相等**、放行集合取自**写入前** manifest、与删除放行集合**互斥**。既有 `validate_version_records_are_append_only` 与既有 `mutate_manifest_at_allowing_version_removals` 语义一行未改;唯一结构性改动是把写盘公共体抽成 `write_manifest_locked_with_version_guard`(校验器由调用方注入,`write_manifest_locked` 变成一行委托),装盘 / 回读 / 原子替换仍只有一份实现。
+- **命令**:`read_local_project_version_resource_replacement_candidates`(只读,`asset.list`)与 `replace_local_project_version_resource`(写,`asset.register`),实现在 `src-tauri/src/project/version_resource_replacement.rs`。入参都是单个 `input` 对象、`deny_unknown_fields`:读 `{projectPath, sourceVersionId, sourceResourceId}`,写额外要求 `expectedProjectId + expectedProjectRevision + replacementResourceId`;出参 `{versionId, committedProjectRevision, replacement:{versionId, sourceResourceId, replacementResourceId, compatibility, warning}}`(**没有** `parentVersionId`,因为没有新版本)。这两个 DTO 是 Tauri 本地 DTO,**不进跨端契约**。
+- **写入语义**:持项目写锁(与删除 / 重命名 / 标签同一把)→ 锁外与锁内各复核一次 `projectId`,锁内复核 durable revision(CAS 失败报 `project-identity-conflict` / `project-revision-conflict` 且零写入)→ 走上面的绑定改写通道,放行集合固定为 `[sourceVersionId]` → 成功后推进一次项目 revision(改绑定属于 `versions` 变化,跨面快照门禁要求 revision 前进)→ 追加一条 `asset.version_binding.replace` 审计(复用既有 `append_agent_db_record`)。审计写失败会报错但不回滚,与 `asset.register` 同口径。
+- **绑定改写语义**(恒等绑定口径的硬约束):`resourceBindings` 是"该版本使用的素材集合"而不是槽位表,因此改写 = **源素材从集合里消失 + 保证替换素材在集合里**;替换素材是源版本创建之后才登记时按源素材原来的位置插回(顺序稳定),早已登记时只做摘除 —— 不能把源素材那条槽位改写成替换素材,那会撞「资源槽位重复」。
+- **准入与提示(后端权威,前端只呈现)**:**硬门禁**只有 `categoryEqual`(用 PRD §5.3 的**读时自愈**口径,Rust 侧 `game_creation_app_asset_category_with_read_time_healing` 与 `packages/shared` 的 `gameCreationAppAssetCategory` 逐分支一致并配用例锁定)与 `subtypeEqual`(canonical `kind`);`sizeSpecEqual` **降级为提示**(「格式与源素材不同」)不再拒绝 —— 它的完整判据今天不存在(manifest 没有 `width / height / durationMs`,现役写入侧几乎全写 `imageSequenceFrames: None`,实际只等于"媒体格式相等"),硬拦会误拒 `png ↔ webp` 这类最常见需求;要变成硬判据须先给 manifest asset 增尺寸字段并在写入侧回填(跨端契约变更)。
+- **入口**:资源卡选中工具条的宿主 `extraActions` 新增「替换素材」,判据复用现役 `isResourceUsedByCurrentVersion`(manifest 身份 + 被当前版本绑定),未绑定素材不渲染入口;不动 `ImageCanvasSelectedLayerToolbarAction` 共享 union、不改 `resourceCanvasToolbarModel` 的 `supportedActions`。候选弹窗复用 `ImageCanvasProjectAssetPickerDialog`(该弹窗在 AGC 侧首次使用),以可选 prop 扩展:`singleSelect` / `assetBlockedReasons` / `assetHints` / `renderAssetMedia` / `selectionNoun` / `errorMessage`,**默认值保持网页端美术画布行为逐字不变**。候选行渲染类型占位而不挂 ``(AGC 的预览要经带 scope 的原生调度器拿 Blob URL,弹窗内没有同步 `src`)。
+- **成功后行为**:重读 manifest,**不切换版本**(没有新版本可切),**不自动重载 / 重启运行中的预览**(PRD §3.2 末条),不做运行时资源重映射。可见变化只有资源卡「当前使用」高亮移到替换素材、`@` 面板「当前版本素材」更新。
+- **已知代价(用户已确认接受)**:**替换历史不可回溯**——替换前身份只剩那条审计与 manifest 的 `.previous` 副本;需要"某版本历史上换过什么"时要另立切片(PRD §3.2 / §5.3 保留为未来合同正是为此)。
+- **验证**:绑定改写通道定向 7 条(`project/manifest/version_binding_rewrite_tests.rs`)+ 替换定向 8 条 + `shared-contracts` 20 条。**变异验证**:① 去掉「未放行版本整条相等」→ 两周转红;② 放行集合改成整个版本数组 → 「未放行版本」转红;③ 去掉长度检查 → 「不增不删」转红;④ 准入删掉 category → 硬门禁与候选两条转红;另有 ⑤ 改回"原地改既有版本但绕过放行口"→ 被「项目版本记录写入后不可修改、删除或重排」拦下。前端 13 条(模型 9 + 真链路 4)。AGC 全量 1231 passed / 4 skipped / 0 failed;共享美术画布组件 1385 passed;`npm run ai-game-creator-shell:typecheck`(含 check-config)、`cargo check --locked --all-targets`、`npm run check:encoding`、`git diff --check` 全绿。`/api/external/v1`、SpacetimeDB schema、`packages/shared` 与 `shared-contracts` 的 wire DTO、manifest 结构、布局 sidecar schema 均未改动。
diff --git a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md
index 3eb4f2e27..16a0b131b 100644
--- a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md
+++ b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md
@@ -231,15 +231,19 @@ api-server 是否本次重启:□ 是  □ 否
 
 ### 7.3 已知未做 / 已取消(不要报成缺陷)
 
-1. **C6 替换关系与替换队列整条取消**(#309)。PRD L387 的 `ProjectVersionResourceReplacement` 兼容性三项属该取消范围。
+1. **C6 的候选素材 / 替换关系 / 替换队列 / Agent 审核 / 批量提交整条取消**(#309)。PRD §5.3 的 `ProjectVersionResourceReplacement` 与 §3.2 的「创建下一迭代版本」**当前不实现**(2026-09-11 用户 DDL 口径「替换这块先做成直接替换」),但**保留为未来合同**:今天的实现是「改该版本的绑定,不建新版本」,验收见 PRD §7.8。
 2. **画布生成入口只覆盖 3 类**(视频 / 音效 / 背景音乐);图片等类型会直接报"当前资源类型不支持无源生成",这是设计而非缺陷。
-3. **工具条 7 个动作按 opt-in 不渲染**:重绘 / 裁剪扩图 / 去背景 / 像素完美 / 切图集 / 提取 UI 素材 / 角色动画;只有 `快速编辑` 与 `下载` 真渲染(宿主另附加 `UI 编辑器` / `编辑标签` / `重命名`)。
-4. **C7 只做记录层 + UI 层**:切换版本即重载当前预览;版本化资源解析机制未实现。
+3. **工具条 7 个动作按 opt-in 不渲染**:重绘 / 裁剪扩图 / 去背景 / 像素完美 / 切图集 / 提取 UI 素材 / 角色动画;只有 `快速编辑` 与 `下载` 真渲染(宿主另附加 `UI 编辑器` / `编辑标签` / `重命名`,以及**只在素材被当前版本绑定时**出现的 `替换素材`)。
+4. **C7 只做记录层 + UI 层**:切换版本即重载当前预览;版本化资源解析机制未实现。资源替换同样不做运行时资源重映射:改绑定只改「这个版本用哪些素材」的记录,运行画面要按游戏自身引用的资源路径渲染。
 5. **「首轮进度投影」无编码级判据**,可见性由底部 Agent 状态栏承载。
 6. **浮出工具条的视觉位置未做真机核验**(jsdom 不执行 Web Animations,只覆盖结构与 class)。
 7. **重命名不改游戏源码中的旧 `assets/` 引用**。
-8. **C9 参考图弹窗在 AGC 侧入口未确认**:全仓检索 `添加参考图` 只命中共享 `src/components/image-editor/*`,AGC 的 `ResourceCanvasGenerationPanelView` 未引用它,可能处于"组件已实现、AGC 入口未接"状态。建议真机点一次生成 / 编辑面板确认;若无入口,C9 不计入本轮主线。
+8. **C9 参考图弹窗在 AGC 侧入口未确认**:全仓检索 `添加参考图` 只命中共享 `src/components/image-editor/*`,AGC 的 `ResourceCanvasGenerationPanelView` 未引用它,可能处于"组件已实现、AGC 入口未接"状态。建议真机点一次生成 / 编辑面板确认;若无入口,C9 不计入本轮主线。**注**:资源替换复用同一个 `ImageCanvasProjectAssetPickerDialog`,它在 AGC 侧是首次使用(同一个弹窗、不同的 opt-in 参数)。
 9. **运行视图存在「点选素材」按钮**,但 #309 的"不做"清单包含运行画面点选,口径冲突待定。
+10. **替换成功后运行画面不会立刻变**:按 C7 与本轮口径只做记录层 + UI 层,可见变化是资源卡"当前使用"高亮移到替换素材与 `@` 面板"当前版本素材"更新;**不会新增版本卡、也不切换版本**,这不等于替换失败。
+11. **替换候选弹窗不加载缩略图**:AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL,弹窗内没有同步 `src`,因此候选行只渲染类型占位(不给 `` 喂空串、不挂破图)。
+12. **尺寸规格差异只提示、不拒绝**:`sizeSpecEqual` 的完整判据今天不存在(manifest 无 `width / height / durationMs`,实际只等于"媒体格式相等"),所以同分类同类型的 `png ↔ webp` 替换是**允许**的,弹窗里只给「格式与源素材不同」的提示。
+13. **替换历史不可回溯**:直接替换不产生版本记录,替换前身份只剩一条 `asset.version_binding.replace` 审计与 manifest 的 `.previous` 副本。这是当前口径的已知代价,不要报成"缺少替换历史功能"的缺陷(版本级替换属未来合同)。
 
 ### 7.4 文档与代码的偏差(需明确按哪个判)
 
@@ -253,5 +257,5 @@ api-server 是否本次重启:□ 是  □ 否
 
 ## 8. 验收边界
 
-- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生、画布生成入口(3 类)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、聊天 @ 引用与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。
-- 本用例不覆盖(另走专项或定向测试):双窗口 CAS 冲突、大规模 fixture 性能、素材创作无限画布阶段一至五的草稿 / 事务 / 恢复矩阵(见 `【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 与配套专题)、UI 编辑器子路由、主站图片编辑器回归(见 PRD §7.7)。
+- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生、画布生成入口(3 类)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、**资源替换(入口放行判据 / 候选禁用与原因 / 格式提示 / 写入载荷 / 拒绝零副作用 / 成功后不切版本不重载预览)**、聊天 @ 引用与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。
+- 本用例不覆盖(另走专项或定向测试):双窗口 CAS 冲突、大规模 fixture 性能、素材创作无限画布阶段一至五的草稿 / 事务 / 恢复矩阵(见 `【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 与配套专题)、UI 编辑器子路由、主站图片编辑器回归(见 PRD §7.7)。资源替换的后端矩阵(版本绑定改写放行的六条不变式与两组互斥、绑定改写两条路径、硬门禁与提示、CAS、四条拒绝路径、读时自愈口径、审计留痕)见 `apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs` 与 `apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs`,前端矩阵见 `apps/ai-game-creator-shell/tests/resourceVersionReplacement*.test.ts(x)`。
diff --git a/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx
index b49a1a872..ef61d91c2 100644
--- a/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx
+++ b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx
@@ -1,8 +1,9 @@
 import { Check, ImageIcon, Music, Search, Video } from 'lucide-react';
-import { useEffect, useMemo, useState } from 'react';
+import { type ReactNode, useEffect, useMemo, useState } from 'react';
 
 import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton';
 import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar';
+import { PlatformStatusMessage } from '../../../packages/shared/src/components/PlatformStatusMessage';
 import { UnifiedModal } from '../common/UnifiedModal';
 import type { EditorAsset } from './ImageCanvasEditorTypes';
 import {
@@ -20,6 +21,41 @@ type ImageCanvasProjectAssetPickerDialogProps = {
   selectedAssetIds: readonly string[];
   onCancel: () => void;
   onConfirm: (assetIds: string[]) => void;
+  /**
+   * 单选模式:点击即整组替换当前选择,不再渲染「已选」chip 行。
+   * 默认 `false`,网页端美术画布的参考图多选行为逐字不变。
+   */
+  singleSelect?: boolean;
+  /**
+   * 被禁用的素材 id → 用户可见的禁用原因。默认空,即所有素材都可选。
+   *
+   * 禁用项仍然渲染(不隐藏):隐藏会让用户以为"素材不存在",而真实原因是它不可替换。
+   */
+  assetBlockedReasons?: Readonly>;
+  /**
+   * 可选素材 id → 非阻断提示(例如"格式与源素材不同")。默认空,即不显示任何提示。
+   *
+   * 与 `assetBlockedReasons` 的区别:提示不改变可点性,只把差异说清楚。
+   */
+  assetHints?: Readonly>;
+  /**
+   * 素材缩略图渲染器。默认 `undefined` → 沿用 ``。
+   *
+   * 宿主(如 AGC 资源工作台)没有同步 `src` 时必须传它:AGC 的预览读取走带 scope 的
+   * 原生调度器 + Blob URL,弹窗内取不到,直接给空 `src` 会挂破图。
+   */
+  renderAssetMedia?: (asset: EditorAsset) => ReactNode;
+  /**
+   * 选择对象的中文名词,用于拼弹窗标题与可访问名称。默认「参考图」,
+   * 即网页端美术画布的现有文案逐字不变。
+   */
+  selectionNoun?: string;
+  /**
+   * 宿主的失败原因(例如后端拒绝了这次替换)。默认 `undefined` → 不渲染。
+   *
+   * 用于在弹窗内说明"为什么这次操作没成功",而不是静默关闭弹窗让用户以为成功了。
+   */
+  errorMessage?: string | null;
 };
 
 function assetIcon(asset: EditorAsset) {
@@ -46,6 +82,12 @@ export function ImageCanvasProjectAssetPickerDialog({
   selectedAssetIds,
   onCancel,
   onConfirm,
+  singleSelect = false,
+  assetBlockedReasons,
+  assetHints,
+  renderAssetMedia,
+  selectionNoun = '参考图',
+  errorMessage,
 }: ImageCanvasProjectAssetPickerDialogProps) {
   const [query, setQuery] = useState('');
   const [category, setCategory] = useState('all');
@@ -76,20 +118,24 @@ export function ImageCanvasProjectAssetPickerDialog({
   });
 
   function toggleAsset(assetId: string) {
-    setSelection((current) =>
-      current.includes(assetId)
+    if (assetBlockedReasons?.[assetId]) return;
+    setSelection((current) => {
+      if (singleSelect) {
+        return current.includes(assetId) ? [] : [assetId];
+      }
+      return current.includes(assetId)
         ? current.filter((item) => item !== assetId)
-        : [...current, assetId],
-    );
+        : [...current, assetId];
+    });
   }
 
   return (
      onConfirm(selection)}
           >
             确认
@@ -126,13 +172,26 @@ export function ImageCanvasProjectAssetPickerDialog({
         
       }
     >
-      {selectedAssets.length > 0 ? (
-        
+ {errorMessage ? ( + + {errorMessage} + + ) : null} + {selectedAssets.length > 0 && !singleSelect ? ( +
{selectedAssets.map((asset) => ( ); })}