diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index d902487c6..bbf431c0a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -2043,6 +2043,9 @@ pub(crate) struct DeleteLocalProjectAssetResult { /// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记; /// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS, /// 失败时 manifest 与 revision 都不变。 +/// - **幂等**:CAS 通过后登记已不在 manifest 里(上一次调用删掉了登记、只在推进 revision +/// 时失败)时按 no-op 成功收敛,并照常推进 revision —— 这是「manifest 已提交但 revision +/// 未推进」那条中断路径的自愈方式,重试不再报「项目资源不存在」。 pub(crate) fn delete_manifest_asset_at( root: &Path, expected_project_id: &str, @@ -2091,12 +2094,21 @@ pub(crate) fn delete_manifest_asset_at( .iter() .position(|asset| asset.id == asset_id) else { - return Err(format!("项目资源不存在:{asset_id}")); + // 幂等收敛:上一次调用可能已经把登记删掉、只在最后推进 revision 时失败 + // (manifest 已落盘、revision 仍停在旧值)。此时 `expectedProjectId` 与 + // `expectedProjectRevision` 两项 CAS 都已通过,唯一正确的处置是把它当 + // no-op 成功 —— 重试再报「项目资源不存在」会让这条命令永远无法自愈。 + // 收敛路径上 `localPath` 回报空串(前端只用 assetId 与 revision)。 + return Ok(()); }; local_path = manifest.assets[index].local_path.clone(); manifest.assets.remove(index); if delete_referenced_versions { - let referenced_version_ids: Vec = + // 引用集合在这里算一次即可;成员判定用 `HashSet`:逐版本 `Vec::contains` + // 在引用该素材的版本很多时是 O(V²)。 + // (版本守卫侧的 `allowed_version_removals` 闭包按约定在写入前另算一次, + // 它拿不到这里的可变借用,所以两次扫描无法合并。) + let referenced_version_ids: std::collections::HashSet = asset_referencing_versions(manifest, asset_id) .into_iter() .map(|version| version.version_id) 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 d6396ea4d..4301041d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1066,14 +1066,21 @@ pub(crate) fn replace_local_project_version_resource( /// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。 /// /// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败 -/// 时把文件改回原名,不留半成品。 +/// 时把文件改回原名,不留半成品。与删除 / 分类更新同口径:持项目写锁后按 `expectedProjectId` +/// 与当前 revision 做 CAS,失败时磁盘、manifest 与 revision 都不动。 #[tauri::command] pub(crate) fn rename_local_project_asset( input: RenameLocalProjectAssetInput, ) -> Result { let root = Path::new(input.project_path.trim()); enforce_project_permission_policy(root, "asset.register")?; - rename_local_project_asset_at(root, &input.asset_id, &input.new_file_name, None) + rename_local_project_asset_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + &input.new_file_name, + ) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs index ecf2b74e5..9dab8ecf4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs @@ -6,10 +6,15 @@ use super::filesystem::validate_portable_project_path_component; /// /// 只收"新文件名"而不是新旧两个全路径:改名被限制在资产当前所在目录内,目录由 manifest /// 里的 `localPath` 决定,调用方无法指定目标目录。 +/// +/// `expectedProjectId` / `expectedProjectRevision` 与删除、分类更新同口径:陈旧客户端 +/// 不允许凭旧 revision 改写素材文件与 manifest。 #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct RenameLocalProjectAssetInput { pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_project_revision: u64, pub(crate) asset_id: String, pub(crate) new_file_name: String, } @@ -22,12 +27,24 @@ pub(crate) struct RenameLocalProjectAssetResult { pub(crate) committed_project_revision: u64, } -/// 故障注入点:让测试在不依赖只读路径的前提下验证"manifest 写失败必须回滚文件改名"。 -/// 生产路径始终传 `None`。 -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum RenameLocalProjectAssetFaultStage { - /// 文件已改名、manifest 尚未写入时失败。 - ManifestWrite, +/// 测试注入:`.agent/runtime/test-fail-next-asset-rename-manifest-write` 存在时,下一步 +/// manifest 写入按失败返回,用来验证"文件已改名必须改回原名"的回滚。 +/// +/// 与 `agent_db` 的 `test-fail-next-*` 同一套约定,且整段是 `#[cfg(test)]`:生产签名 +/// ([`rename_local_project_asset_at`])不接受任何故障注入参数,也没有第二个入口能把函数 +/// 推上"只回滚、绝不写 manifest"的那条路。 +#[cfg(test)] +fn take_rename_manifest_write_failure_injection(root: &Path) -> Result<(), String> { + let failure_path = root.join(".agent/runtime/test-fail-next-asset-rename-manifest-write"); + match std::fs::read_to_string(&failure_path) { + Ok(_) => { + std::fs::remove_file(&failure_path) + .map_err(|error| format!("清理素材改名 manifest 写失败注入标记失败:{error}"))?; + Err("fault-injected:rename-asset-manifest-write".to_string()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("读取素材改名 manifest 写失败注入标记失败:{error}")), + } } /// 校验调用方给出的新文件名,返回 trim 后的名字。 @@ -64,9 +81,9 @@ fn asset_file_extension(file_name: &str) -> Option { /// 一个帧的 `imageSrc` 是否就是被改名的那份本地文件。 /// -/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后文件名与旧名 -/// 一致(大小写不敏感,和扩展名比较同口径)。只认真正指向该文件的帧,同目录但指向其它文件的帧 -/// 保持原样,避免改名连带改坏别的引用。 +/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后目录与旧 +/// 目录等价、文件名与旧名一致(都按所在文件系统的大小写语义比较)。只认真正指向该文件的帧, +/// 同目录但指向其它文件的帧保持原样,避免改名连带改坏别的引用。 fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_name: &str) -> bool { let image_src = image_src.trim(); if image_src.is_empty() @@ -81,7 +98,21 @@ fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_nam Some((directory, file_name)) => (directory, file_name), None => ("", image_src), }; - frame_directory == directory && frame_file_name.eq_ignore_ascii_case(previous_file_name) + asset_frame_directory_matches(frame_directory, directory) + && frame_file_name.eq_ignore_ascii_case(previous_file_name) +} + +/// 目录段的大小写语义必须与所在文件系统一致。 +/// +/// 文件名一直按大小写不敏感比较(`Assets/Hero.png` 与 `assets/hero.png` 在 Windows / macOS 上 +/// 是同一份文件);目录段如果按大小写敏感比较,同一份文件在 `imageSequenceFrames` 里写成 +/// 不同目录大小写时就对齐不上,改名后留下悬空帧引用。Linux 保持大小写敏感。 +fn asset_frame_directory_matches(frame_directory: &str, directory: &str) -> bool { + if cfg!(any(windows, target_os = "macos")) { + frame_directory.eq_ignore_ascii_case(directory) + } else { + frame_directory == directory + } } /// 把 manifest 条目里指向被改名文件的序列帧对齐到新 `localPath`。 @@ -125,26 +156,53 @@ fn rollback_asset_file_rename( /// /// 事务顺序(全程持既有项目写锁,期间不会出现第二个项目写者;manifest 写入仍走既有边界: /// 版本数组不可变校验 + 安装后回读一致性校验): +/// 0. 与删除 / 分类更新同口径的 CAS:`expectedProjectId` 两段复核 + 当前 revision 比对, +/// 失败时磁盘、manifest、revision 全都不动; /// 1. 读 manifest 定位资产,取旧 `localPath`; /// 2. 校验新文件名、扩展名一致、目标不冲突,然后 `rename` 磁盘文件; /// 3. 更新 manifest 的 `localPath`(`id` / `kind` / `mediaType` / `source` / `category` / `tags` /// 全部不变),并把 `imageSequenceFrames` 里指向该文件的帧对齐到新路径; /// 4. 写 manifest; /// 5. 第 4 步失败时把文件改回原名,不留"文件已改名但 manifest 还是旧路径"的半成品; -/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required。 +/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required; +/// 6. 第 4 步成功、第 6 步(推进 revision)失败时**不回滚**:文件与 manifest 都已提交, +/// 这是已提交但对账未完成的状态,按 `reconciliation-required:` 前缀上报,不按普通失败 +/// 处理(普通失败会诱导调用方"重试一次",把已提交的改名再走一遍)。 +/// 此时磁盘与 manifest 都在**新**名字上,同名重试会命中上面的 no-op 分支直接成功, +/// 所以调用方按"已经改名成功"刷新即可收敛。 pub(crate) fn rename_local_project_asset_at( root: &Path, + expected_project_id: &str, + expected_project_revision: u64, asset_id: &str, new_file_name: &str, - fault: Option, ) -> Result { let asset_id = asset_id.trim(); if asset_id.is_empty() { return Err("素材重命名 assetId 不能为空".to_string()); } let new_file_name = normalize_asset_file_name(new_file_name)?; + 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()); + } + 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 manifest_path = root.join(".agent/manifest.json"); let mut manifest = read_existing_manifest_for_project(root)?; let index = manifest @@ -232,12 +290,13 @@ pub(crate) fn rename_local_project_asset_at( let asset = manifest.assets[index].clone(); // 文件已改名:从这里开始的任何失败都必须把文件改回原名。 - let write_error = match fault { - Some(RenameLocalProjectAssetFaultStage::ManifestWrite) => { - Some("fault-injected:rename-asset-manifest-write".to_string()) - } - None => write_manifest(&manifest_path, &manifest).err(), - }; + // 注入结果必须并进 `write_error`、不能就地 `?` 返回,否则会绕过下面的回滚。 + #[cfg(test)] + let injected_write_error = take_rename_manifest_write_failure_injection(root).err(); + #[cfg(not(test))] + let injected_write_error: Option = None; + let write_error = + injected_write_error.or_else(|| write_manifest(&manifest_path, &manifest).err()); if let Some(error) = write_error { return Err(rollback_asset_file_rename( ¤t_absolute, @@ -246,8 +305,13 @@ pub(crate) fn rename_local_project_asset_at( )); } - let committed_project_revision = advance_agent_runtime_project_revision_locked(root) - .map_err(|error| format!("素材已改名,但项目 revision 未能推进:{error}"))?; + let committed_project_revision = advance_agent_runtime_project_revision_locked(root).map_err( + |error| { + format!( + "reconciliation-required: 素材已改名为 {next_local_path} 且 manifest 已落盘,但项目 revision 未能推进:{error}" + ) + }, + )?; Ok(RenameLocalProjectAssetResult { asset, previous_local_path, 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 39ed157a6..38518a7f7 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 @@ -1108,7 +1108,8 @@ pub(crate) struct UpdateLocalProjectResourceClassificationResult { /// 只更新单个 manifest 资产条目的功能分类与自定义标签,保持条目字段构成与顺序不变。 /// `category` 必须是 6 个合法值之一,非法值直接失败、不回退到 `kind` 派生; -/// `tags` 按既有归一化语义 trim、去空、去重。 +/// `tags` 按既有归一化语义 trim、去空、去重,并在此处再按数量与单标签长度收口: +/// manifest 会被整份读、整份解析、整份重写,无界标签等于让后续每一次读写都为它付费。 pub(crate) fn update_manifest_asset_classification_at( root: &Path, expected_project_id: &str, @@ -1132,12 +1133,12 @@ pub(crate) fn update_manifest_asset_classification_at( } let category = game_creation_app_asset_category_from_str(category) .ok_or_else(|| format!("非法资源分类:{}", category.trim()))?; - let tags = normalize_game_creation_app_asset_tags(&tags); + let tags = normalize_manifest_asset_tags(&tags)?; 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")?; + let _lock = acquire_project_write_lock(root, "asset.classification.update")?; if read_existing_manifest_for_project(root)?.project_id != expected_project_id { return Err("project-identity-conflict".to_string()); } @@ -1164,19 +1165,26 @@ pub(crate) fn update_manifest_asset_classification_at( // 与 `assets.rs` 的 `asset.register` / `asset.update` 同一位置约定(manifest 写成功后追加)。 // 放在推进 revision 之前是刻意的——分类已经真实落盘,审计不能因为紧随其后的 revision 推进 // 失败而缺失,否则「改过但查不到」正是这条缺陷要修的东西;推进失败仍然照旧报错(见下方 map_err)。 - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "asset.classification.update", - "assetId": asset.id, - "expectedProjectRevision": expected_project_revision, - "previousCategory": previous_category, - "previousTags": previous_tags, - "category": asset.category, - "tags": asset.tags, - }), - ) - .map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?; + // + // 这条位置约定的代价是"重试会走到同一个值":上次调用可能已经写进同一份分类、只在推进 + // revision 时失败(revision 未变,所以重试会再次通过 CAS 并重新落到同一个值)。前后值完全 + // 相同的记录不是"从 X 改成 Y",重复追加只会污染这份审计要保住的语义,因此跳过。 + let classification_changed = previous_category != asset.category || previous_tags != asset.tags; + if classification_changed { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "asset.classification.update", + "assetId": asset.id, + "expectedProjectRevision": expected_project_revision, + "previousCategory": previous_category, + "previousTags": previous_tags, + "category": asset.category, + "tags": asset.tags, + }), + ) + .map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?; + } let committed_project_revision = advance_agent_runtime_project_revision_locked(root) .map_err(|error| format!("资源分类已写入,但项目 revision 未能推进:{error}"))?; Ok(UpdateLocalProjectResourceClassificationResult { @@ -1333,6 +1341,32 @@ fn normalize_manifest_task_id_list( Ok(output) } +/// 资源标签的持久化上界:manifest 每次写入都被整份序列化重写、每次读取都被整份解析, +/// 标签数量与单标签长度若无界,客户端就能让这份文件无限膨胀,并把成本摊到之后每一次读写上。 +const ASSET_CLASSIFICATION_MAX_TAGS: usize = 16; +const ASSET_CLASSIFICATION_MAX_TAG_CHARS: usize = 32; + +/// 复用共享契约的归一化(trim / 去空 / 去重),再按 manifest 自己的持久化上界失败关闭。 +/// +/// 这里刻意不做"超长就截断":截断后的标签与共享标签库里的同一条不再相等, +/// 用户看到的标签和实际存下来的标签会静默分叉,问题只是从"文件变大"换成"标签对不上"。 +fn normalize_manifest_asset_tags(tags: &[String]) -> Result, String> { + let normalized = normalize_game_creation_app_asset_tags(tags); + for tag in &normalized { + if tag.chars().count() > ASSET_CLASSIFICATION_MAX_TAG_CHARS { + return Err(format!( + "单个资源标签不能超过 {ASSET_CLASSIFICATION_MAX_TAG_CHARS} 个字符" + )); + } + } + if normalized.len() > ASSET_CLASSIFICATION_MAX_TAGS { + return Err(format!( + "资源标签最多支持 {ASSET_CLASSIFICATION_MAX_TAGS} 个" + )); + } + Ok(normalized) +} + pub(crate) fn read_or_create_manifest( root: &Path, ) -> Result<(PathBuf, GameCreationAppManifest), String> { @@ -1556,14 +1590,20 @@ fn remove_manifest_backup(path: &Path) -> Result<(), String> { /// 这条缓存只服务「这个资源是否已登记」这一层判断;它不放松 `file.read` 权限、项目边界、 /// 敏感路径、普通文件或读取漂移门禁 —— 那些门禁分别在 [`crate::commands::read_local_project_image_preview_at`] /// 与真实字节读取路径上执行,且真实读取从不经过本缓存。 +/// +/// 填充侧同样是"快照 → 读取 → 复核快照":读取前后文件身份一致才入缓存。 pub(crate) fn read_manifest_cached_for_preview( path: &Path, ) -> Result { if let Some(cached) = cached_preview_manifest_if_unchanged(path) { return Ok(cached); } + // **先取身份快照,再读内容**:反过来(先读内容、后取身份)时,读取与取身份之间的一次 + // 并发替换会让缓存里出现"旧内容 + 新文件身份",此后每个读取都会通过身份复核并一直返回 + // 旧 manifest,直到下一次显式失效。读完复核一次身份,只有两次快照一致才允许入缓存。 + let before = preview_manifest_snapshot(path); let manifest = read_manifest(path)?; - remember_preview_manifest(path, &manifest); + remember_preview_manifest_if_stable(path, before, &manifest); Ok(manifest) } @@ -1575,6 +1615,16 @@ struct CachedPreviewManifest { manifest: GameCreationAppManifest, } +/// 缓存命中判据里"这一份文件此刻是什么"的快照:长度 + 修改时间 + 句柄身份。 +/// +/// 阅读侧与写入侧共用同一个快照函数,保证"缓存的内容"与"快照的身份"永远是同一时刻取的。 +#[derive(Clone, Debug, Eq, PartialEq)] +struct PreviewManifestSnapshot { + len: u64, + modified: Option, + identity: Option<(u64, u64)>, +} + /// 缓存容量只需覆盖「同时打开的项目数」,取 8 已远大于实际并发。 const PREVIEW_MANIFEST_CACHE_LIMIT: usize = 8; @@ -1596,8 +1646,9 @@ fn forget_preview_manifest(path: &Path) { } } -fn cached_preview_manifest_if_unchanged(path: &Path) -> Option { - let key = path.to_string_lossy().into_owned(); +/// 取当前文件身份快照;路径不是普通文件(缺失、符号链接、目录、reparse point)时返回 `None`, +/// 表示"这份文件此刻不可缓存"。 +fn preview_manifest_snapshot(path: &Path) -> Option { let metadata = fs::symlink_metadata(path).ok()?; if metadata.file_type().is_symlink() || !metadata.is_file() { return None; @@ -1605,17 +1656,27 @@ fn cached_preview_manifest_if_unchanged(path: &Path) -> Option Option { + let key = path.to_string_lossy().into_owned(); + let snapshot = preview_manifest_snapshot(path)?; let mut cache = preview_manifest_cache().lock().ok()?; let index = cache .iter() .position(|(cached_key, _)| cached_key == &key)?; let cached = &cache[index].1; - if cached.len != len || cached.modified != modified || cached.identity != identity { + if cached.len != snapshot.len + || cached.modified != snapshot.modified + || cached.identity != snapshot.identity + { cache.remove(index); return None; } @@ -1626,17 +1687,27 @@ fn cached_preview_manifest_if_unchanged(path: &Path) -> Option, + manifest: &GameCreationAppManifest, +) { + let Some(before) = before else { return; }; - if metadata.file_type().is_symlink() || !metadata.is_file() { + if preview_manifest_snapshot(path).as_ref() != Some(&before) { return; } - let identity = fs::File::open(path) - .ok() - .and_then(|file| open_file_identity_key(&file).ok()); + remember_preview_manifest(path, before, manifest); +} + +fn remember_preview_manifest( + path: &Path, + snapshot: PreviewManifestSnapshot, + manifest: &GameCreationAppManifest, +) { + let key = path.to_string_lossy().into_owned(); let Ok(mut cache) = preview_manifest_cache().lock() else { return; }; @@ -1646,9 +1717,9 @@ fn remember_preview_manifest(path: &Path, manifest: &GameCreationAppManifest) { cache.push_back(( key, CachedPreviewManifest { - len: metadata.len(), - modified: metadata.modified().ok(), - identity, + len: snapshot.len, + modified: snapshot.modified, + identity: snapshot.identity, manifest: manifest.clone(), }, )); @@ -1841,11 +1912,9 @@ where } let _write_lock = acquire_manifest_write_lock(path)?; after_lock(); - let result = write_manifest_locked(path, manifest, allowed_version_removals); - if result.is_ok() { - forget_preview_manifest(path); - } - result + // 预览缓存的失效放在装盘公共体里(见 `write_manifest_locked_with_version_guard`), + // 这里不再单独调用一次:所有写入通道必须共用同一个失效点。 + write_manifest_locked(path, manifest, allowed_version_removals) } fn write_manifest_locked( @@ -1915,6 +1984,14 @@ fn write_manifest_locked_with_version_guard( "manifest 临时文件", )?; install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?; + // 文件已经被替换:从这一刻起缓存里的内容与磁盘不再对应,必须失效。 + // + // 失效点放在这个唯一的装盘公共体里,所有写入通道(`write_manifest`、`mutate_manifest_at`、 + // `mutate_manifest_at_allowing_version_removals`、`mutate_manifest_allowing_version_binding_rewrites`) + // 都覆盖到;命中判据只有长度 + mtime + 文件身份,兜不住同一 mtime 粒度内的同长度原地改写, + // 而 Windows 上某些卷的文件身份又是恒等常量。失败路径(安装后回读不一致)同样要失效 —— + // 那时文件也已经换过了,留着旧缓存才是错的。 + forget_preview_manifest(path); prepare_game_creator_private_path_for_read(path, false, "manifest")?; let installed = read_manifest(path)?; if installed != *manifest { @@ -1965,6 +2042,8 @@ mod classification_tests; #[cfg(test)] mod import_tests; #[cfg(test)] +mod preview_cache_tests; +#[cfg(test)] mod recovery_tests; #[cfg(test)] mod version_binding_rewrite_tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs index 2c62a6b5e..d66a0d736 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs @@ -420,6 +420,106 @@ fn asset_classification_rejections_do_not_append_agent_db_audit_records() { fs::remove_dir_all(root).ok(); } +/// 审计只记「真的改了什么」:把同一个分类与同一组标签再写一次不留记录。 +/// +/// 这条同时是「上一次已写入、只在推进 revision 时失败」那种中断态的重试语义: +/// 那次重试会重新落到同一个值,如果照旧追加,审计里就会出现一条 +/// `previousCategory == category` 的假变更记录。 +#[test] +fn asset_classification_repeat_write_does_not_append_a_noop_audit_record() { + let (root, asset_id) = project_with_two_assets("audit-idempotent"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before") + .revision; + + let first = update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "scene", + vec!["主舞台".to_string()], + ) + .expect("first classification update"); + assert_eq!(first.committed_project_revision, revision_before + 1); + assert_eq!(classification_audit_records(&root).len(), 1); + + let repeat = update_manifest_asset_classification_at( + &root, + &project_id, + first.committed_project_revision, + &asset_id, + "scene", + vec!["主舞台".to_string()], + ) + .expect("repeating the same classification must succeed"); + assert_eq!(repeat.committed_project_revision, revision_before + 2); + assert_eq!(repeat.asset.category, GameCreationAppAssetCategory::Scene); + assert_eq!(repeat.asset.tags, vec!["主舞台"]); + assert_eq!( + classification_audit_records(&root).len(), + 1, + "同一个值重复写入不得追加假变更审计" + ); + + fs::remove_dir_all(root).ok(); +} + +/// 标签必须在写入前有界:数量与单标签长度都要拒绝,manifest 与 revision 都不动。 +#[test] +fn asset_classification_rejects_unbounded_tags() { + let (root, asset_id) = project_with_two_assets("tag-bounds"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before") + .revision; + let manifest_before = read_existing_manifest_for_project(&root).expect("read manifest before"); + + let too_many = (0..17) + .map(|index| format!("标签{index}")) + .collect::>(); + let many_error = update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "scene", + too_many, + ) + .expect_err("too many tags must be rejected"); + assert!(many_error.contains("最多支持"), "unexpected: {many_error}"); + + let too_long = vec!["像".repeat(33)]; + let long_error = update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "scene", + too_long, + ) + .expect_err("an over-long tag must be rejected"); + assert!(long_error.contains("不能超过"), "unexpected: {long_error}"); + + assert_eq!( + read_existing_manifest_for_project(&root).expect("read manifest after"), + manifest_before + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after") + .revision, + revision_before + ); + + fs::remove_dir_all(root).ok(); +} + /// 审计写失败必须可见:分类已经落盘的既定行为不变(不静默回滚),但命令要报错, /// 而且不能留下一条假的审计。 #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/preview_cache_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/preview_cache_tests.rs new file mode 100644 index 000000000..f2b98e721 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/preview_cache_tests.rs @@ -0,0 +1,165 @@ +use super::*; + +/// 独立的临时项目根:缓存是进程内全局状态,按路径做键,所以每个用例必须用自己的路径。 +fn preview_cache_test_root(test_name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-manifest-preview-cache-{test_name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) +} + +fn preview_cache_manifest_path(root: &Path) -> PathBuf { + let path = root.join(".agent/manifest.json"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create preview cache manifest directory"); + } + path +} + +/// 直接落盘一份 manifest 内容(不走 `write_manifest`,避免它自己带来的缓存失效干扰断言)。 +fn install_preview_cache_manifest(path: &Path, manifest: &GameCreationAppManifest) { + let payload = serde_json::to_string_pretty(manifest).expect("serialize preview cache manifest"); + fs::write(path, format!("{payload}\n")).expect("install preview cache manifest"); +} + +fn preview_cache_entry_count(path: &Path) -> usize { + let key = path.to_string_lossy().into_owned(); + preview_manifest_cache() + .lock() + .expect("lock preview manifest cache") + .iter() + .filter(|(cached_key, _)| cached_key == &key) + .count() +} + +/// 读取前后身份一致时正常入缓存并命中。 +#[test] +fn preview_cache_serves_a_repeated_read_from_the_snapshot() { + let root = preview_cache_test_root("hit"); + let path = preview_cache_manifest_path(&root); + let manifest = new_game_creation_app_manifest("preview-cache-project", "预览缓存项目"); + install_preview_cache_manifest(&path, &manifest); + + let first = read_manifest_cached_for_preview(&path).expect("first cached read"); + assert_eq!(first.project_id, "preview-cache-project"); + assert_eq!(preview_cache_entry_count(&path), 1); + + let second = read_manifest_cached_for_preview(&path).expect("second cached read"); + assert_eq!(second, first); + assert_eq!(preview_cache_entry_count(&path), 1); + + fs::remove_dir_all(root).ok(); +} + +/// 读取期间文件被并发替换(快照对不上)时不得入缓存: +/// 否则缓存里会出现"旧内容 + 新文件身份",之后每个读取都会通过身份复核并一直返回旧内容。 +#[test] +fn preview_cache_skips_insertion_when_the_file_changed_during_the_read() { + let root = preview_cache_test_root("toc-tou"); + let path = preview_cache_manifest_path(&root); + let stale = new_game_creation_app_manifest("preview-cache-stale", "旧内容"); + let fresh = new_game_creation_app_manifest("preview-cache-fresh", "新内容"); + install_preview_cache_manifest(&path, &stale); + + // 读之前取快照 ⇒ 这里模拟"读完之后、入缓存之前"文件被并发写者替换。 + // 生产写入是"临时文件 + rename",所以这里也用 rename:原地 `fs::write` 改写不会换文件身份, + // 长度与 mtime 又可能落在同一粒度内,快照根本区分不出来(那正是写入侧必须显式失效的原因)。 + let before = preview_manifest_snapshot(&path).expect("snapshot before read"); + let replacement_path = path.with_file_name(".manifest.json.concurrent"); + install_preview_cache_manifest(&replacement_path, &fresh); + fs::rename(&replacement_path, &path).expect("replace manifest concurrently"); + remember_preview_manifest_if_stable(&path, Some(before), &stale); + + assert_eq!( + preview_cache_entry_count(&path), + 0, + "读取期间文件变化时不得写入缓存" + ); + let read = read_manifest_cached_for_preview(&path).expect("read after replacement"); + assert_eq!(read.project_id, "preview-cache-fresh"); + + fs::remove_dir_all(root).ok(); +} + +/// 缓存命中判据里"文件身份"这一维必须真的在起作用: +/// 用同长度内容 + 原封不动的 mtime 走 rename 替换文件(长度与修改时间都对得上,只有身份变了), +/// 旧条目必须被丢弃并重新读取,否则缓存会一直返回替换前的 manifest。 +#[test] +fn preview_cache_drops_entries_whose_file_identity_changed() { + let root = preview_cache_test_root("identity-drift"); + let path = preview_cache_manifest_path(&root); + let stale = new_game_creation_app_manifest("preview-cache-before", "替换前"); + let fresh = new_game_creation_app_manifest("preview-cache-after-", "替换后"); + install_preview_cache_manifest(&path, &stale); + let cached = read_manifest_cached_for_preview(&path).expect("prime the cache"); + assert_eq!(cached.project_id, "preview-cache-before"); + + let original = fs::metadata(&path).expect("original manifest metadata"); + let replacement_path = path.with_file_name(".manifest.json.replacement"); + install_preview_cache_manifest(&replacement_path, &fresh); + assert_eq!( + fs::metadata(&replacement_path) + .expect("replacement metadata") + .len(), + original.len(), + "这条用例要求替换内容与原内容同长度" + ); + let replacement = fs::OpenOptions::new() + .write(true) + .open(&replacement_path) + .expect("open replacement manifest"); + replacement + .set_times(fs::FileTimes::new().set_modified(original.modified().expect("original mtime"))) + .expect("restore replacement mtime"); + drop(replacement); + fs::rename(&replacement_path, &path).expect("replace manifest by rename"); + + let read = read_manifest_cached_for_preview(&path).expect("read after external replacement"); + assert_eq!( + read.project_id, "preview-cache-after-", + "长度与修改时间都一致时,只有文件身份能拦住外部替换后的旧缓存" + ); + + fs::remove_dir_all(root).ok(); +} + +/// 主写入通道必须让预览缓存失效:`mutate_manifest_at_allowing_version_removals` +/// (素材登记 / 删除 / 分类更新 / 版本增删)走的是 `write_manifest_locked`, +/// 曾经是唯一不调用 `forget_preview_manifest` 的写入路径。 +#[test] +fn mutate_manifest_at_allowing_version_removals_invalidates_the_preview_cache() { + let root = preview_cache_test_root("invalidate"); + let path = preview_cache_manifest_path(&root); + let mut manifest = new_game_creation_app_manifest("preview-cache-mutate", "原始项目名"); + manifest.goal = Some("原始目标".to_string()); + install_preview_cache_manifest(&path, &manifest); + let cached = read_manifest_cached_for_preview(&path).expect("prime the cache"); + assert_eq!(cached.goal.as_deref(), Some("原始目标")); + assert_eq!(preview_cache_entry_count(&path), 1); + + // 同长度原地改写:命中判据里的长度不变,只有显式失效能保证不返回旧内容。 + mutate_manifest_at_allowing_version_removals(&root, &no_version_removals, |manifest| { + manifest.goal = Some("新目标值".to_string()); + Ok(()) + }) + .expect("mutate manifest"); + + assert_eq!( + preview_cache_entry_count(&path), + 0, + "写入通道必须显式失效预览缓存" + ); + + let read = read_manifest_cached_for_preview(&path).expect("read after mutation"); + assert_eq!( + read.goal.as_deref(), + Some("新目标值"), + "写入通道必须显式失效预览缓存" + ); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs index b5e4d3c06..90c1367c8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs @@ -354,7 +354,8 @@ fn version_removal_stays_rejected_outside_the_confirmed_asset_delete() { fs::remove_dir_all(root).ok(); } -/// 删除同样走 CAS:陈旧 revision、跨项目身份与不存在的素材都被拒绝,manifest 与 revision 不变。 +/// 删除同样走 CAS:陈旧 revision、跨项目身份都被拒绝,manifest 与 revision 不变; +/// 登记已经不在 manifest 里时按幂等 no-op 成功收敛(见下一条用例)。 #[test] fn asset_delete_enforces_revision_and_identity_cas() { let root = asset_delete_project_fixture(); @@ -371,14 +372,6 @@ fn asset_delete_enforces_revision_and_identity_cas() { .expect_err("cross-project identity must be rejected"); assert_eq!(identity_error, "project-identity-conflict"); - let missing_error = - delete_manifest_asset_at(&root, "project-1", revision_before, "absent", true) - .expect_err("missing asset must be rejected"); - assert!( - missing_error.contains("项目资源不存在"), - "unexpected error: {missing_error}" - ); - let manifest = read_manifest_for_project(&root).expect("read manifest after rejected deletes"); assert!( manifest.assets.iter().any(|asset| asset.id == asset_id), @@ -392,3 +385,39 @@ fn asset_delete_enforces_revision_and_identity_cas() { fs::remove_dir_all(root).ok(); } + +/// 「manifest 已提交、revision 未推进」那条中断路径必须能自愈: +/// 用同一个 `expectedProjectRevision` 重试要按 no-op 成功收敛,并补推进 revision。 +#[test] +fn asset_delete_retry_converges_when_only_the_revision_advance_failed() { + let root = asset_delete_project_fixture(); + let asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan"); + let revision_before = project_revision(&root); + + delete_asset_at_current_revision(&root, &asset_id, false).expect("first delete commits"); + let committed = project_revision(&root); + assert_eq!(committed, revision_before + 1); + + // 复现中断态:登记已经删掉,但 revision 被写回旧值(等价于当年推进失败)。 + let mut revision = read_game_creator_agent_runtime_project_revision(&root).expect("read"); + revision.revision = revision_before; + write_game_creator_agent_runtime_project_revision(&root, &revision).expect("rewind revision"); + + let retry = delete_manifest_asset_at(&root, "project-1", revision_before, &asset_id, false) + .expect("retry must converge instead of reporting a missing asset"); + assert_eq!(retry.asset_id, asset_id); + assert_eq!(retry.committed_project_revision, revision_before + 1); + assert!(retry.file_retained); + assert_eq!( + project_revision(&root), + revision_before + 1, + "收敛路径必须补推进 revision,否则工作区会停在旧 revision" + ); + let manifest = read_manifest_for_project(&root).expect("read manifest after convergent retry"); + assert!( + !manifest.assets.iter().any(|asset| asset.id == asset_id), + "收敛路径不得把登记写回去" + ); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs index 9242c4559..21764f49f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs @@ -48,6 +48,21 @@ fn asset_rename_revision(root: &Path) -> u64 { .revision } +/// 用当前 revision 发起重命名:模拟前端的正常调用(CAS 要求调用方带上它读到的 revision)。 +fn rename_asset_at_current_revision( + root: &Path, + asset_id: &str, + new_file_name: &str, +) -> Result { + rename_local_project_asset_at( + root, + "project-1", + asset_rename_revision(root), + asset_id, + new_file_name, + ) +} + /// 成功改名:磁盘文件真的换了名字,manifest 只改 `localPath`,资产 `id` 与其余字段不变, /// 项目 revision 前进一格。 #[test] @@ -56,8 +71,8 @@ fn rename_local_project_asset_renames_file_and_keeps_asset_identity() { let before = asset_rename_entry(&root, &asset_id); let revision_before = asset_rename_revision(&root); - let result = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None) - .expect("rename asset"); + let result = + rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset"); assert_eq!(result.previous_local_path, "assets/hero.png"); assert_eq!(result.asset.local_path, "assets/hero-idle.png"); @@ -93,7 +108,7 @@ fn rename_local_project_asset_is_a_noop_for_the_same_file_name() { let revision_before = asset_rename_revision(&root); let result = - rename_local_project_asset_at(&root, &asset_id, "hero.png", None).expect("no-op rename"); + rename_asset_at_current_revision(&root, &asset_id, "hero.png").expect("no-op rename"); assert_eq!(result.previous_local_path, "assets/hero.png"); assert_eq!(result.asset.local_path, "assets/hero.png"); @@ -125,7 +140,7 @@ fn rename_local_project_asset_rejects_unsafe_file_names() { "..png", "assets/../hero.png", ] { - let error = rename_local_project_asset_at(&root, &asset_id, name, None) + let error = rename_asset_at_current_revision(&root, &asset_id, name) .expect_err("unsafe file name must be rejected"); assert!( error.contains("不能为空") || error.contains("路径分隔符") || error.contains(".."), @@ -148,7 +163,7 @@ fn rename_local_project_asset_rejects_cross_directory_targets() { let (root, asset_id) = asset_rename_project_fixture(); fs::create_dir_all(root.join("assets/nested")).expect("nested dir"); - let error = rename_local_project_asset_at(&root, &asset_id, "nested/hero.png", None) + let error = rename_asset_at_current_revision(&root, &asset_id, "nested/hero.png") .expect_err("cross directory rename must be rejected"); assert!(error.contains("路径分隔符"), "意外错误:{error}"); @@ -168,7 +183,7 @@ fn rename_local_project_asset_rejects_extension_changes() { let (root, asset_id) = asset_rename_project_fixture(); for name in ["hero.jpg", "hero", "hero.png.bak"] { - let error = rename_local_project_asset_at(&root, &asset_id, name, None) + let error = rename_asset_at_current_revision(&root, &asset_id, name) .expect_err("extension change must be rejected"); assert!(error.contains("扩展名"), "意外错误:{error}"); } @@ -183,7 +198,7 @@ fn rename_local_project_asset_rejects_extension_changes() { asset_rename_source(), ) .expect("register extensionless asset"); - let error = rename_local_project_asset_at(&root, &extensionless.id, "notes.txt", None) + let error = rename_asset_at_current_revision(&root, &extensionless.id, "notes.txt") .expect_err("adding an extension must be rejected"); assert!(error.contains("扩展名"), "意外错误:{error}"); @@ -205,7 +220,7 @@ fn rename_local_project_asset_rejects_existing_target_name() { .expect("other asset file"); let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"); - let error = rename_local_project_asset_at(&root, &asset_id, "other.png", None) + let error = rename_asset_at_current_revision(&root, &asset_id, "other.png") .expect_err("existing target must be rejected"); assert!(error.contains("同名文件"), "意外错误:{error}"); @@ -230,12 +245,12 @@ fn rename_local_project_asset_rejects_existing_target_name() { fn rename_local_project_asset_requires_an_existing_asset_and_file() { let (root, asset_id) = asset_rename_project_fixture(); - let error = rename_local_project_asset_at(&root, "missing-asset", "hero-idle.png", None) + let error = rename_asset_at_current_revision(&root, "missing-asset", "hero-idle.png") .expect_err("unknown asset must be rejected"); assert!(error.contains("项目资源不存在"), "意外错误:{error}"); fs::remove_file(root.join("assets/hero.png")).expect("remove asset file"); - let error = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None) + let error = rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png") .expect_err("missing file must be rejected"); assert!(error.contains("素材文件不存在"), "意外错误:{error}"); assert!(!root.join("assets/hero-idle.png").exists()); @@ -253,14 +268,14 @@ fn rename_local_project_asset_rolls_back_the_file_when_manifest_write_fails() { let (root, asset_id) = asset_rename_project_fixture(); let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"); let revision_before = asset_rename_revision(&root); - - let error = rename_local_project_asset_at( - &root, - &asset_id, - "hero-idle.png", - Some(RenameLocalProjectAssetFaultStage::ManifestWrite), + fs::write( + root.join(".agent/runtime/test-fail-next-asset-rename-manifest-write"), + "注入下一次 manifest 写失败", ) - .expect_err("injected manifest write failure must surface"); + .expect("write rename manifest failure injection marker"); + + let error = rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png") + .expect_err("injected manifest write failure must surface"); assert!(error.contains("fault-injected"), "意外错误:{error}"); assert_eq!( @@ -277,6 +292,101 @@ fn rename_local_project_asset_rolls_back_the_file_when_manifest_write_fails() { fs::remove_dir_all(root).ok(); } +/// 重命名同样走 CAS:陈旧 revision 与跨项目身份都被拒绝,磁盘、manifest 与 revision 都不动。 +#[test] +fn rename_local_project_asset_enforces_revision_and_identity_cas() { + let (root, asset_id) = asset_rename_project_fixture(); + let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"); + let revision_before = asset_rename_revision(&root); + + let revision_error = rename_local_project_asset_at( + &root, + "project-1", + revision_before + 1, + &asset_id, + "hero-idle.png", + ) + .expect_err("stale revision must be rejected"); + assert_eq!(revision_error, "project-revision-conflict"); + + let identity_error = rename_local_project_asset_at( + &root, + "project-2", + revision_before, + &asset_id, + "hero-idle.png", + ) + .expect_err("cross-project identity must be rejected"); + assert_eq!(identity_error, "project-identity-conflict"); + + let blank_identity_error = + rename_local_project_asset_at(&root, " ", revision_before, &asset_id, "hero-idle.png") + .expect_err("blank expectedProjectId must be rejected"); + assert!( + blank_identity_error.contains("不能为空"), + "{blank_identity_error}" + ); + + assert!(root.join("assets/hero.png").is_file()); + assert!(!root.join("assets/hero-idle.png").exists()); + assert_eq!( + fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"), + manifest_before + ); + assert_eq!(asset_rename_revision(&root), revision_before); + + fs::remove_dir_all(root).ok(); +} + +/// `imageSequenceFrames` 的目录段按文件系统大小写语义比较:同一份文件写成别的目录大小写 +/// 也要被对齐(Windows / macOS 上 `Assets/hero.png` 与 `assets/hero.png` 是同一份文件)。 +#[test] +fn rename_local_project_asset_aligns_frames_with_case_insensitive_directory() { + let (root, asset_id) = asset_rename_project_fixture(); + mutate_manifest_at(&root, |manifest| { + let asset = manifest + .assets + .iter_mut() + .find(|asset| asset.id == asset_id) + .expect("asset entry"); + asset.image_sequence_frames = Some( + [ + ("Assets/hero.png", 32u32, 32u32), + ("other/hero.png", 64, 64), + ] + .into_iter() + .map( + |(image_src, width, height)| GameCreationAppImageSequenceFrame { + image_src: image_src.to_string(), + object_key: None, + asset_object_id: None, + width, + height, + }, + ) + .collect(), + ); + Ok(()) + }) + .expect("seed image sequence frames"); + + rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset"); + + let frames = asset_rename_entry(&root, &asset_id) + .image_sequence_frames + .expect("image sequence frames"); + let expected = if cfg!(any(windows, target_os = "macos")) { + "assets/hero-idle.png" + } else { + "Assets/hero.png" + }; + assert_eq!(frames[0].image_src, expected); + // 指向别的目录的帧在任何平台都不动。 + assert_eq!(frames[1].image_src, "other/hero.png"); + + fs::remove_dir_all(root).ok(); +} + /// `imageSequenceFrames` 里指向被改名文件的本地帧对齐到新路径,指向别的文件或远程地址的帧不动。 #[test] fn rename_local_project_asset_aligns_image_sequence_frames_of_the_renamed_file() { @@ -309,7 +419,7 @@ fn rename_local_project_asset_aligns_image_sequence_frames_of_the_renamed_file() }) .expect("seed image sequence frames"); - rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None).expect("rename asset"); + rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset"); let frames = asset_rename_entry(&root, &asset_id) .image_sequence_frames