diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 442b1342e..3d56329d7 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -61,6 +61,7 @@ "react-window": "^1.8.11", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", + "three": "^0.184.0", "vite": "^6.2.0", "zustand": "^5.0.14" }, @@ -72,6 +73,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.184.1", "@types/react-window": "^1.8.8", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index ba259ccf3..f5cc8b673 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -33,7 +33,11 @@ chromiumoxide = "0.9.1" futures = "0.3" getrandom = "0.3" http = "1" -image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } +# `tga` / `tiff` / `hdr` 只服务资源画布的只读预览:引擎图像容器(Cocos 的 +# .tga/.tif/.hdr 等)在浏览器里没有解码器,必须先在原生侧转码成 PNG 再送给前端。 +# 刻意不开 `exr`:它要求 `exr ^1.74.0`,当前依赖源只能到 1.73,打不开就先让 +# `.exr` 走「类型卡」而不是留一半解不出来的预览分支。 +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "tga", "tiff", "hdr"] } jsonschema = { version = "0.49.3", default-features = false } oxc_allocator = "0.143.0" oxc_ast = "0.143.0" diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md index 84fb51e53..f25ab993d 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -10,7 +10,7 @@ Let the client derive projections from real disk changes and trusted tool result ## Workflow 1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media. -2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId. +2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, code, or engine asset such as a Cocos `.glb`/`.prefab`/`.anim`/`.mtl`/`.plist`/`.texture`) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId. 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. 5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md index 63ddd9a82..9129efc1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -8,7 +8,7 @@ The client projects three distinct facts: Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance. -`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS audio, MP4/WEBM/MOV video, recognized text documents, and recognized source-code files. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools. +`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP/TGA/TIFF/HDR images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS/PCM audio, MP4/WEBM/MOV video, recognized text documents, recognized source-code files, and engine (Cocos Creator) assets such as `.glb`/`.gltf`/`.fbx`/`.mesh`/`.skeleton` models, `.anim`/`.animation`/`.animgraph`/`.animgraphvari`/`.animask` animation clips, `.scene`/`.fire`/`.prefab`/`.tmx`/`.terrain`, `.mtl`/`.material`/`.pmtl`/`.effect`/`.chunk`, `.plist`/`.labelatlas`/`.atlas`/`.fnt`/`.pac`, and engine containers such as `.texture`/`.cubemap`/`.rt`/`.dbbin`/`.bin`/`.skel`/`.psd`/`.znt`/`.exr`. `asset.list`/`kind` filtering classifies models as `model` and undecodable engine containers as `binary`; both are discovery categories, not manifest kinds. Registered engine assets are read-only previews on the resource canvas — models render a thumbnail, serialized assets show a structure summary, and containers that the client cannot decode show a type card. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools. Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 385454c47..6debfefb3 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.19", + "version": "2026-08-26.20", "skills": [ { "name": "agc-game-production-workflow", @@ -123,7 +123,7 @@ "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1" + "sha256": "93210c0eeb73b279d35aa85c201c226139b0bdf041f3300ac2c6e2c1bdd63afe" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 945b16c1e..706015112 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -5305,7 +5305,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; if prepare_art { - system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档或代码文件,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。"); + system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档、代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等),先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。"); emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码"); } else { system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。客户端会把结构化证据回灌同一会话。"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 9e569eaea..f31a3cdd4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1380,6 +1380,33 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => ("code", Some("text/plain")), + /* + * Cocos Creator 资源(3.8.8 的 `engine-extends` 贡献的 `asset-handler` 表)。 + * + * 这里只做**发现分类**:`model` / `binary` 是发现层新词,与 manifest 资产 `kind` + * 不是同一套口径(登记时的 kind 见 `commands.rs::agent_local_project_file_type`)。 + * 只有 `mediaType` 非空的条目才会 `assetImportable=true`,因此这张表必须与登记层 + * 的白名单同步增删;`prompt_context.rs::prompt_context_media_type` 是同一套扩展名的 + * 第三份投影,同样要跟改。 + */ + "glb" => ("model", Some("model/gltf-binary")), + "gltf" => ("model", Some("model/gltf+json")), + "fbx" => ("model", Some("application/octet-stream")), + "mesh" | "skeleton" => ("model", Some("application/json")), + "scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari" + | "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" => { + ("document", Some("application/json")) + } + "tmx" | "plist" => ("document", Some("application/xml")), + "effect" | "chunk" | "fnt" | "atlas" => ("document", Some("text/plain")), + "tga" => ("image", Some("image/x-tga")), + "tif" | "tiff" => ("image", Some("image/tiff")), + "hdr" => ("image", Some("image/vnd.radiance")), + "exr" => ("image", Some("image/x-exr")), + "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => { + ("binary", Some("application/octet-stream")) + } + "pcm" => ("audio", Some("audio/pcm")), _ => ("other", None), } } @@ -1421,8 +1448,10 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { .map(|value| value.to_lowercase()); let requested_kind = bridge_optional_bounded_string(arguments, "kind", 16)? .unwrap_or_else(|| "all".to_string()); - if !["all", "image", "font", "audio", "video", "document", "code"] - .contains(&requested_kind.as_str()) + if ![ + "all", "image", "font", "audio", "video", "document", "code", "model", "binary", + ] + .contains(&requested_kind.as_str()) { return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); } @@ -3181,7 +3210,9 @@ mod tests { "supported raster image should be importable: {path}" ); } - for path in ["assets/theme.bin", "assets/unknown.xyz"] { + // `.bin` 现在是引擎的 BufferAsset 载体(Cocos 资源表里的 `buffer` handler), + // 因此不再属于「未识别文件」;真正未识别的扩展名仍然只能被发现。 + for path in ["assets/theme.dat", "assets/unknown.xyz"] { assert!( !bridge_project_file_is_asset_importable(path), "unsupported project file must not be advertised as importable: {path}" @@ -3201,6 +3232,64 @@ mod tests { } } + /// Cocos Creator 资源在发现层必须同时满足两件事:给出可筛选的类别、且 `mediaType` + /// 非空(`assetImportable` 由它推导,是 Agent 唯一能提交登记的入口)。 + /// + /// 变异验证:把任一扩展名从 `bridge_project_file_class` 删掉,本用例必须变红。 + #[test] + fn bridge_project_file_class_covers_cocos_creator_assets() { + for (path, expected_class, expected_media_type) in [ + ("assets/model/hero.glb", "model", "model/gltf-binary"), + ("assets/model/hero.gltf", "model", "model/gltf+json"), + ("assets/model/hero.fbx", "model", "application/octet-stream"), + ("assets/model/hero.mesh", "model", "application/json"), + ("assets/model/hero.skeleton", "model", "application/json"), + ("assets/scene/main.scene", "document", "application/json"), + ("assets/scene/enemy.prefab", "document", "application/json"), + ("assets/anim/walk.anim", "document", "application/json"), + ( + "assets/anim/graph.animgraph", + "document", + "application/json", + ), + ("assets/mtl/hero.mtl", "document", "application/json"), + ("assets/shader/glow.effect", "document", "text/plain"), + ("assets/atlas/hero.plist", "document", "application/xml"), + ("assets/map/level.tmx", "document", "application/xml"), + ("assets/font/bitmap.fnt", "document", "text/plain"), + ("assets/atlas/auto.pac", "document", "application/json"), + ("assets/tex/grass.tga", "image", "image/x-tga"), + ("assets/tex/height.hdr", "image", "image/vnd.radiance"), + ( + "assets/tex/hero.texture", + "binary", + "application/octet-stream", + ), + ( + "assets/spine/hero.skel", + "binary", + "application/octet-stream", + ), + ("assets/audio/voice.pcm", "audio", "audio/pcm"), + ] { + let (class, media_type) = bridge_project_file_class(path); + assert_eq!(class, expected_class, "{path}"); + assert_eq!(media_type, Some(expected_media_type), "{path}"); + assert!( + bridge_project_file_is_asset_importable(path), + "Cocos 资源必须可登记:{path}" + ); + } + // 引擎工程里的导入缓存不是资源:`.meta` 与未知扩展名仍然只能被发现。 + for path in ["assets/tex/hero.png.meta", "assets/world.unknown"] { + assert_eq!(bridge_project_file_class(path).0, "other", "{path}"); + assert!( + !bridge_project_file_is_asset_importable(path), + "非资源文件不得可登记:{path}" + ); + } + } + #[test] fn bridge_project_file_listing_projects_importability_per_file() { let temporary = tempfile::tempdir().expect("create project file listing root"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index f30331490..e9d9415df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -369,7 +369,8 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab }, "kind": { "type": "string", - "enum": ["all", "image", "font", "audio", "video", "document", "code"] + "enum": ["all", "image", "font", "audio", "video", "document", "code", "model", "binary"], + "description": "image/font/audio/video/document/code 是通用类别;model 是 Cocos 等引擎的三维模型数据,binary 是只能发现、当前无法在客户端预览的二进制资源" }, "offset": { "type": "integer", "minimum": 0, "maximum": 500 }, "limit": { "type": "integer", "minimum": 1, "maximum": 100 } @@ -771,7 +772,10 @@ fn validate_project_file_list_arguments(arguments: &Value) -> Result<(), String> } if arguments.get("kind").is_some() { let kind = bounded_tool_string(arguments, "kind", 16)?; - if !["all", "image", "font", "audio", "video", "document", "code"].contains(&kind.as_str()) + if ![ + "all", "image", "font", "audio", "video", "document", "code", "model", "binary", + ] + .contains(&kind.as_str()) { return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 9d1f102c9..dc09f659e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -161,7 +161,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result Option<&'static str> { "js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"), + /* + * Cocos Creator 资源。这里只是给未登记文件清单标注媒体类型,取值必须与 + * `agent/direct_tool_bridge.rs::bridge_project_file_class` 和 + * `commands.rs::agent_local_project_file_type` 一致:少写一个扩展名, + * Agent 的提示词就会把「其实可以登记」的资源说成只能发现。 + */ + "glb" => Some("model/gltf-binary"), + "gltf" => Some("model/gltf+json"), + "fbx" | "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => { + Some("application/octet-stream") + } + "scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari" + | "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" | "mesh" + | "skeleton" => Some("application/json"), + "tmx" | "plist" => Some("application/xml"), + "effect" | "chunk" | "fnt" | "atlas" => Some("text/plain"), + "tga" => Some("image/x-tga"), + "tif" | "tiff" => Some("image/tiff"), + "hdr" => Some("image/vnd.radiance"), + "exr" => Some("image/x-exr"), + "pcm" => Some("audio/pcm"), _ => None, } } @@ -246,10 +267,21 @@ mod tests { ("assets/data.json", "application/json"), ("game/index.html", "text/html"), ("game/main.rs", "text/plain"), + // 引擎资源同样要在未登记清单里被标出可登记:漏一个扩展名, + // Agent 就会把「其实可以登记」的 Cocos 资源说成只能发现。 + ("assets/model/hero.glb", "model/gltf-binary"), + ("assets/model/hero.fbx", "application/octet-stream"), + ("assets/anim/walk.anim", "application/json"), + ("assets/scene/main.scene", "application/json"), + ("assets/shader/glow.effect", "text/plain"), + ("assets/atlas/hero.plist", "application/xml"), + ("assets/tex/grass.tga", "image/x-tga"), + ("assets/audio/voice.pcm", "audio/pcm"), ] { assert_eq!(prompt_context_media_type(path), Some(expected), "{path}"); } - assert_eq!(prompt_context_media_type("assets/unknown.bin"), None); + // `.bin` 现在是引擎 BufferAsset 的载体,不再是「未识别」;真正未知的扩展名才返回 None。 + assert_eq!(prompt_context_media_type("assets/unknown.dat"), None); } } 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 739dc5a3f..a2e86d06e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -3092,7 +3092,7 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_registers_multiple_types_and_is_idempotent() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); @@ -3185,7 +3185,7 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); @@ -3202,7 +3202,7 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_rejects_hidden_and_build_tree_sources() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); @@ -3232,20 +3232,147 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_rejects_unknown_and_invalid_text_files() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); fs::create_dir_all(root.join("assets")).expect("create assets directory"); - fs::write(root.join("assets/unknown.bin"), b"bytes").expect("write unknown file"); + fs::write(root.join("assets/unknown.dat"), b"bytes").expect("write unknown file"); fs::write(root.join("assets/broken.js"), [0xff, 0xfe]).expect("write invalid source"); assert!( - import_local_project_assets_for_agent(root, &["assets/unknown.bin".to_string()]) + import_local_project_assets_for_agent(root, &["assets/unknown.dat".to_string()]) .is_err() ); assert!( import_local_project_assets_for_agent(root, &["assets/broken.js".to_string()]).is_err() ); + // `.bin` 是引擎的 BufferAsset 载体,属于「已识别但只能出类型卡」的一类: + // 登记必须成功,否则引擎工程里的 BufferAsset 永远进不了资源画布。 + fs::write(root.join("assets/blob.bin"), [0x00, 0x01, 0x02]).expect("write buffer asset"); + let imported = + import_local_project_assets_for_agent(root, &["assets/blob.bin".to_string()]) + .expect("import engine buffer asset"); + assert_eq!(imported.assets.len(), 1); + assert_eq!(imported.assets[0].asset_kind.as_deref(), Some("document")); + } + + /// Cocos Creator 资源登记:模型、动画、序列化资源与引擎容器都要能进 manifest, + /// 且 `kind` 只落在**既有 canonical 词表**里(不新增契约值,旧客户端仍能读 manifest)。 + /// + /// 变异验证:把任一扩展名从 `agent_local_project_file_type` 删掉即变红。 + #[test] + fn local_project_asset_import_registers_cocos_creator_assets() { + // 工程自带助手:canonicalize + 目录 owner 归当前用户,避免 `%TEMP%` 临时目录 + // 在 Windows owner 校验下直接失败。 + let project = crate::tests::canonical_test_tempdir("cocos-asset-import-"); + let root = project.path(); + init_local_game_project_at(root, "cocos-import", "Cocos import") + .expect("initialize project"); + for directory in [ + "model", "anim", "scene", "mtl", "shader", "atlas", "map", "tex", "audio", + ] { + fs::create_dir_all(root.join("assets").join(directory)).expect("create assets subdir"); + } + let mut glb = b"glTF".to_vec(); + glb.extend_from_slice(&[2, 0, 0, 0, 12, 0, 0, 0]); + let mut fbx = b"Kaydara FBX Binary \x00".to_vec(); + fbx.extend_from_slice(&[0; 16]); + for (path, bytes) in [ + ("assets/model/hero.glb", glb), + ("assets/model/hero.fbx", fbx), + ( + "assets/anim/walk.anim", + b"[{\"__type__\":\"cc.AnimationClip\"}]".to_vec(), + ), + ( + "assets/anim/graph.animgraph", + b"{\"__type__\":\"cc.animation.AnimationGraph\"}".to_vec(), + ), + ( + "assets/scene/main.scene", + b"[{\"__type__\":\"cc.SceneAsset\"}]".to_vec(), + ), + ( + "assets/scene/enemy.prefab", + b"[{\"__type__\":\"cc.Prefab\"}]".to_vec(), + ), + ( + "assets/mtl/hero.mtl", + b"{\"__type__\":\"cc.Material\"}".to_vec(), + ), + ("assets/shader/glow.effect", b"CCEffect %{\n}".to_vec()), + ( + "assets/atlas/hero.plist", + b"".to_vec(), + ), + ( + "assets/map/level.tmx", + b"".to_vec(), + ), + ("assets/tex/hero.texture", vec![0xff, 0x00, 0x01]), + ("assets/audio/voice.pcm", vec![0x00, 0x01, 0x02]), + ] { + fs::write(root.join(path), bytes).expect("write cocos asset"); + } + + let relative_paths = [ + "assets/model/hero.glb", + "assets/model/hero.fbx", + "assets/anim/walk.anim", + "assets/anim/graph.animgraph", + "assets/scene/main.scene", + "assets/scene/enemy.prefab", + "assets/mtl/hero.mtl", + "assets/shader/glow.effect", + "assets/atlas/hero.plist", + "assets/map/level.tmx", + "assets/tex/hero.texture", + "assets/audio/voice.pcm", + ] + .map(str::to_string) + .to_vec(); + let imported = + import_local_project_assets_for_agent(root, &relative_paths).expect("import cocos"); + let kinds = imported + .assets + .iter() + .map(|asset| (asset.local_path.as_str(), asset.asset_kind.as_deref())) + .collect::>(); + assert_eq!(kinds.get("assets/model/hero.glb"), Some(&Some("scene"))); + assert_eq!(kinds.get("assets/model/hero.fbx"), Some(&Some("scene"))); + assert_eq!( + kinds.get("assets/anim/walk.anim"), + Some(&Some("character-animation")) + ); + assert_eq!( + kinds.get("assets/anim/graph.animgraph"), + Some(&Some("character-animation")) + ); + assert_eq!(kinds.get("assets/scene/main.scene"), Some(&Some("scene"))); + assert_eq!(kinds.get("assets/scene/enemy.prefab"), Some(&Some("scene"))); + assert_eq!(kinds.get("assets/mtl/hero.mtl"), Some(&Some("code"))); + assert_eq!(kinds.get("assets/shader/glow.effect"), Some(&Some("code"))); + assert_eq!( + kinds.get("assets/atlas/hero.plist"), + Some(&Some("document")) + ); + // 瓦片地图与场景/预制体同栏(`scene`),不是「文档」:它描述的是可摆放的地图。 + assert_eq!(kinds.get("assets/map/level.tmx"), Some(&Some("scene"))); + assert_eq!( + kinds.get("assets/tex/hero.texture"), + Some(&Some("document")) + ); + assert_eq!(kinds.get("assets/audio/voice.pcm"), Some(&Some("audio"))); + + // 非 UTF-8 的 `.prefab` 会被 `document` 分支拒绝:结构化文本资源必须是 UTF-8, + // 否则「结构预览」拿到的是一堆乱码。 + fs::write(root.join("assets/scene/broken.prefab"), [0xff, 0xfe]) + .expect("write invalid prefab"); + assert!(import_local_project_assets_for_agent( + root, + &["assets/scene/broken.prefab".to_string()] + ) + .is_err()); } /// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**, @@ -3962,6 +4089,140 @@ fn agent_local_project_file_type( }, max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), + /* + * Cocos Creator(3.8.8)资源:三维模型、动画、材质/特效、场景/预制体、图集与 + * 压缩纹理容器。登记边界只做两件事:给出可判定的 `asset_kind` 与**预览通道** + * 能支撑的 `media_type`。 + * + * - `document`:Cocos 自己序列化的文本/JSON(要过 UTF-8 校验),卡面按结构预览; + * - `binary`:客户端无法解码的容器(模型、压缩纹理、Spine 二进制等),只要求非空; + * - `image`:可用原生解码转码成 PNG 再预览的图像容器(tga/tif/tiff/hdr/exr)。 + * + * 这些扩展名必须与 `agent/direct_tool_bridge.rs::bridge_project_file_class` 和 + * `agent/generation/prompt_context.rs::prompt_context_media_type` 同步,否则会出现 + * 「发现得了、登记不了」或「登记得了、Agent 看不见」的分叉。 + */ + "scene" | "fire" | "prefab" | "terrain" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "scene", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "tmx" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "scene", + media_type: "application/xml", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "anim" | "animation" | "animgraph" | "animgraphvari" | "animask" => { + Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "character-animation", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }) + } + "mtl" | "material" | "pmtl" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "code", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "effect" | "chunk" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "code", + media_type: "text/plain", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "plist" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: "application/xml", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "labelatlas" | "pac" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "fnt" | "atlas" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: "text/plain", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "glb" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "scene", + media_type: "model/gltf-binary", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "gltf" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "scene", + media_type: "model/gltf+json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "fbx" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "scene", + media_type: "application/octet-stream", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "mesh" | "skeleton" => Some(AgentLocalProjectFileType { + // Cocos 的 `.mesh` / `.skeleton` 是网格与骨骼的实例化数据,多数工程里是 + // JSON、但也存在二进制变体,因此只按「非空」校验;能不能当文本预览由 + // 结构化预览读取自己判定(非 UTF-8 时降级成类型卡)。 + category: "binary", + asset_kind: "scene", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" => { + Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "document", + media_type: "application/octet-stream", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }) + } + "psd" | "znt" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "image", + media_type: "application/octet-stream", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "tga" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/x-tga", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "tif" | "tiff" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/tiff", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "hdr" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/vnd.radiance", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "exr" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/x-exr", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "pcm" => Some(AgentLocalProjectFileType { + category: "audio", + asset_kind: "audio", + media_type: "audio/pcm", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), _ => None, }; let file_type = file_type.ok_or_else(|| format!("本地文件类型不受支持:{relative_path}"))?; @@ -5055,6 +5316,65 @@ pub(crate) fn read_local_project_text_preview_at( Ok(preview) } +/** + * 读取引擎(Cocos)序列化资源的**只读结构预览**。 + * + * 与文本预览分开的理由:`.prefab` / `.scene` / `.anim` / `.effect` 这些扩展名不属于 + * 「可编辑文本资源」白名单(那份名单同时服务 UI 编辑器),把它们并进去会顺手改变 + * UI 编辑链路的准入;这里只服务资源画布的卡面预览,且允许非 UTF-8 的二进制变体 + * 降级成类型卡(`content: null`),不把「不能预览」报成错误。 + */ +#[tauri::command] +pub(crate) async fn read_local_project_structured_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_structured_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +pub(crate) fn read_local_project_structured_preview_at( + project_path: &str, + relative_path: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + cancellation.check()?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?; + cancellation.check()?; + let registered_media_type = manifest + .assets + .iter() + .find(|asset| asset.local_path == normalized_path) + .map(|asset| asset.media_type.clone()); + let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| { + is_supported_project_structured_resource(&normalized_path, media_type) + }) || manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_structured_resource(&normalized_path, "") + }); + if !is_registered_structured { + return Err("只能读取当前项目已登记的引擎资源".to_string()); + } + cancellation.check()?; + load_local_project_structured_preview_with_cancellation( + root, + &normalized_path, + registered_media_type.as_deref().unwrap_or(""), + cancellation, + ) +} + #[tauri::command] pub(crate) async fn read_local_project_media_preview( preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, @@ -5094,7 +5414,8 @@ pub(crate) fn read_local_project_media_preview_at( let kind = match category.trim() { "art" => ProjectMediaPreviewKind::Art, "audio" => ProjectMediaPreviewKind::Audio, - _ => return Err("媒体预览类别只支持 art 或 audio".to_string()), + "model" => ProjectMediaPreviewKind::Model, + _ => return Err("媒体预览类别只支持 art、audio 或 model".to_string()), }; let is_registered_media = manifest.assets.iter().any(|asset| { asset.local_path == normalized_path @@ -5105,6 +5426,9 @@ pub(crate) fn read_local_project_media_preview_at( ProjectMediaPreviewKind::Audio => { is_supported_project_audio_resource(&asset.local_path, &asset.media_type) } + ProjectMediaPreviewKind::Model => { + is_supported_project_model_resource(&asset.local_path, &asset.media_type) + } } }) || manifest.tasks.iter().any(|task| { task.status == GameCreationAppTaskStatus::Completed @@ -5116,6 +5440,9 @@ pub(crate) fn read_local_project_media_preview_at( ProjectMediaPreviewKind::Audio => { is_supported_project_audio_resource(&normalized_path, "") } + ProjectMediaPreviewKind::Model => { + is_supported_project_model_resource(&normalized_path, "") + } } }); if !is_registered_media { 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 60d3113a3..68ba228eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2630,6 +2630,7 @@ fn main() { read_local_project_image_preview, save_local_project_asset_file, read_local_project_text_preview, + read_local_project_structured_preview, read_local_project_media_preview, cancel_local_project_resource_preview_scope, write_local_project_file, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 6f067628b..1e634bbda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -1,5 +1,21 @@ use super::*; +/** + * Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。 + * + * 判定刻意收窄到「工程根的**直接子目录**且名字是这四个之一」:`assets/library/` 是资源 + * 目录里的普通文件夹,不属于这里。 + */ +fn is_engine_generated_root_directory(relative_path: &str) -> bool { + if relative_path.contains('/') { + return false; + } + matches!( + relative_path.to_ascii_lowercase().as_str(), + "library" | "temp" | "profiles" | "local" + ) +} + pub(crate) fn list_local_project_files_at( root: &Path, ) -> Result { @@ -11,6 +27,15 @@ pub(crate) fn list_local_project_files_at( }); } + /* + * 引擎生成目录的过滤只在**当前目录确实是 Cocos Creator 工程**时生效。 + * + * 这里不能把 `library` / `temp` / `profiles` / `local` 加进全局跳过表:这些名字在 + * 别的工程里可能是真实源码目录(例如自带 `library/` 的库工程)。而 Cocos 工程的 + * `library/` 是导入缓存,常有上万条生成文件,既会挤满 Agent 的发现窗口,也会让 + * 前端资源树加载一堆永远用不上的条目。 + */ + let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some(); let mut files = Vec::new(); let mut dirs = vec![root.to_path_buf()]; while let Some(dir) = dirs.pop() { @@ -41,6 +66,11 @@ pub(crate) fn list_local_project_files_at( .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) .unwrap_or(0); if file_type.is_dir() { + if skip_engine_generated_directories + && is_engine_generated_root_directory(&relative_path) + { + continue; + } files.push(LocalProjectFileEntry { path: relative_path, kind: "directory".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs index d7ca4b4ba..3a666b253 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -13,6 +13,18 @@ use std::path::Path; const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; +/** + * 模型预览的字节上限。 + * + * 模型要整份送进渲染器才能出预览,而预览载荷是 base64 data URL(约放大 1/3): + * 与通用媒体上限(`PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES`)取同一个 32 MiB, + * 覆盖绝大多数 `.glb` / `.fbx`,同时把单条 IPC 载荷压在约 43 MiB 以内。 + * + * 超过上限的模型不报错、也不半渲染:卡面直接降级成类型卡(见前端 + * `projectResourceCardPreviewKind` 的 `model` 分支)。真要再往上放宽,得先把预览载荷 + * 从 base64 JSON 换成 Tauri 的原始字节通道,否则 JS 侧的字符串副本会先炸掉内存。 + */ +const PROJECT_MODEL_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024; const PROJECT_MEDIA_PREVIEW_MAX_DIMENSION: u32 = 8_192; const PROJECT_MEDIA_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024; @@ -46,6 +58,68 @@ pub(crate) struct LocalProjectMediaPreview { pub(crate) enum ProjectMediaPreviewKind { Art, Audio, + /// 三维模型(`.glb` / `.gltf` / `.fbx`):整份字节交给前端渲染器出预览。 + Model, +} + +/** + * 引擎资源结构预览的读取结果。 + * + * `content` 为 `None` 表示该资源不是 UTF-8 文本(例如二进制的 `.pac` 变体)—— + * 这是**正常降级**而不是错误:卡面据此画类型卡,不再向用户报「预览失败」。 + */ +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectStructuredPreview { + pub(crate) path: String, + pub(crate) media_type: String, + pub(crate) byte_len: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) content: Option, +} + +/** + * 引擎(Cocos 等)序列化资源的结构预览准入。 + * + * 与 `is_supported_project_text_resource` **刻意分开**:那份白名单同时服务 UI 编辑器 + * 的「可编辑文本资源」判定,把 `.prefab` / `.scene` / `.anim` 塞进去会让它们在这条 + * 编辑链路里被当成普通文本资产;这里只服务资源画布的只读结构预览。 + */ +pub(crate) fn is_supported_project_structured_resource(path: &str, media_type: &str) -> bool { + if !matches!( + path_extension(path).as_deref(), + Some( + "scene" + | "fire" + | "prefab" + | "anim" + | "animation" + | "animgraph" + | "animgraphvari" + | "animask" + | "mtl" + | "material" + | "pmtl" + | "effect" + | "chunk" + | "tmx" + | "plist" + | "labelatlas" + | "atlas" + | "fnt" + | "pac" + | "mesh" + | "skeleton" + ) + ) { + return false; + } + let media_type = media_type.trim().to_ascii_lowercase(); + !(media_type.starts_with("audio/") + || media_type.starts_with("video/") + || media_type.starts_with("image/") + || media_type.starts_with("model/") + || media_type.starts_with("font/")) } pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -> bool { @@ -116,11 +190,33 @@ pub(crate) fn is_supported_project_art_media_resource(path: &str, media_type: &s let media_type = media_type.trim().to_ascii_lowercase(); matches!( path_extension(path).as_deref(), - Some("gif" | "svg" | "avif" | "bmp" | "mp4" | "webm" | "mov") + Some( + "gif" + | "svg" + | "avif" + | "bmp" + | "mp4" + | "webm" + | "mov" + // 引擎图像容器:浏览器解不了,先由原生侧转码成 PNG 再走同一条预览链路。 + | "tga" + | "tif" + | "tiff" + | "hdr" + ) ) || media_type.starts_with("video/") || media_type == "image/svg+xml" } +/// 模型预览准入:引擎三维模型与网格数据(Cocos 的 `.glb` / `.gltf` / `.fbx`)。 +pub(crate) fn is_supported_project_model_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("glb" | "gltf" | "fbx") + ) || media_type.starts_with("model/") +} + pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) -> bool { let media_type = media_type.trim().to_ascii_lowercase(); matches!( @@ -184,6 +280,56 @@ pub(crate) fn load_local_project_media_preview( ) } +/** + * 读取引擎序列化资源的结构预览。 + * + * `media_type` 由调用方从 manifest 登记项透传(任务产物没有登记项,传空串): + * 这条读取**不自己维护第五份扩展名表**,准入判定与登记口径共用同一个函数。 + */ +pub(crate) fn load_local_project_structured_preview_with_cancellation( + root: &Path, + relative_path: &str, + media_type: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; + let normalized = normalize_relative_path(relative_path.trim())?; + reject_sensitive_project_file_read(&normalized)?; + if !is_supported_project_structured_resource(&normalized, media_type) { + return Err("结构预览只支持引擎序列化资源".to_string()); + } + let bytes = read_stable_project_resource( + root, + &normalized, + PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES, + "引擎资源", + cancellation, + )?; + cancellation.check()?; + let byte_len = bytes.len() as u64; + let media_type = project_structured_media_type(&normalized); + Ok(LocalProjectStructuredPreview { + path: normalized, + media_type: media_type.to_string(), + byte_len, + // 非 UTF-8 的二进制变体不是错误:返回 `None`,由卡面降级成类型卡。 + content: String::from_utf8(bytes).ok(), + }) +} + +fn project_structured_media_type(path: &str) -> &'static str { + match path_extension(path).as_deref() { + Some("effect" | "chunk" | "fnt" | "atlas") => "text/plain", + Some("tmx" | "plist") => "application/xml", + Some( + "scene" | "fire" | "prefab" | "terrain" | "anim" | "animation" | "animgraph" + | "animgraphvari" | "animask" | "mtl" | "material" | "pmtl" | "labelatlas" | "pac" + | "mesh" | "skeleton", + ) => "application/json", + _ => "application/octet-stream", + } +} + pub(crate) fn load_local_project_media_preview_with_cancellation( root: &Path, relative_path: &str, @@ -193,29 +339,107 @@ pub(crate) fn load_local_project_media_preview_with_cancellation( cancellation.check()?; let normalized = normalize_relative_path(relative_path.trim())?; reject_sensitive_project_file_read(&normalized)?; - let bytes = read_stable_project_resource( - root, - &normalized, - PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, - "项目媒体资源", - cancellation, - )?; + let (max_bytes, label) = match kind { + ProjectMediaPreviewKind::Model => (PROJECT_MODEL_PREVIEW_MAX_FILE_BYTES, "项目模型资源"), + ProjectMediaPreviewKind::Art | ProjectMediaPreviewKind::Audio => { + (PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, "项目媒体资源") + } + }; + let bytes = read_stable_project_resource(root, &normalized, max_bytes, label, cancellation)?; if bytes.is_empty() { return Err("媒体文件为空,无法预览".to_string()); } cancellation.check()?; - let media_type = detect_project_media_type(&normalized, &bytes, kind)?; - cancellation.check()?; - let dimensions = (kind == ProjectMediaPreviewKind::Art) - .then(|| detect_project_art_dimensions(&bytes, media_type)) - .flatten(); + let source_byte_len = bytes.len() as u64; + /** + * 图像容器(TGA / TIFF / HDR / EXR)在浏览器里没有解码器:先在原生侧转成 PNG, + * 再走与 GIF / BMP / AVIF 完全相同的「数据 URL + 图片卡」链路。转码是**只读**的, + * 不改工程文件;像素尺寸仍受既有的尺寸与像素总量上限约束,不会因为多一层解码 + * 就放宽任何一条既有边界。 + */ + let transcoded = (kind == ProjectMediaPreviewKind::Art) + .then(|| transcode_project_art_container(&normalized, &bytes)) + .flatten() + .transpose()?; + let (media_type, dimensions, payload) = match transcoded { + Some(transcoded) => ( + transcoded.media_type, + Some((transcoded.pixel_width, transcoded.pixel_height)), + transcoded.bytes, + ), + None => { + let media_type = detect_project_media_type(&normalized, &bytes, kind)?; + cancellation.check()?; + let dimensions = (kind == ProjectMediaPreviewKind::Art) + .then(|| detect_project_art_dimensions(&bytes, media_type)) + .flatten(); + (media_type, dimensions, bytes) + } + }; Ok(LocalProjectMediaPreview { path: normalized, media_type: media_type.to_string(), - byte_len: bytes.len() as u64, + // `byteLen` 始终是**源文件**的大小:转码只影响预览载荷,不改变「这是多大的资源」。 + byte_len: source_byte_len, pixel_width: dimensions.map(|(width, _)| width), pixel_height: dimensions.map(|(_, height)| height), - data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?, + data_url: encode_project_resource_preview_data_url(media_type, &payload, cancellation)?, + }) +} + +struct TranscodedProjectArtContainer { + bytes: Vec, + media_type: &'static str, + pixel_width: u32, + pixel_height: u32, +} + +fn transcode_project_art_container( + relative_path: &str, + bytes: &[u8], +) -> Option> { + let format = match path_extension(relative_path).as_deref()? { + "tga" => image::ImageFormat::Tga, + "tif" | "tiff" => image::ImageFormat::Tiff, + "hdr" => image::ImageFormat::Hdr, + _ => return None, + }; + Some(transcode_project_art_container_with_format( + relative_path, + bytes, + format, + )) +} + +fn transcode_project_art_container_with_format( + relative_path: &str, + bytes: &[u8], + format: image::ImageFormat, +) -> Result { + let decoded = image::load_from_memory_with_format(bytes, format) + .map_err(|error| format!("图像容器解码失败,无法在客户端预览:{relative_path}: {error}"))? + // HDR / EXR 是浮点像素格式,PNG 只能装整数像素:统一降到 8 位 RGBA, + // 与其它预览图(含 alpha 版)保持同一种像素口径。 + .to_rgba8(); + let (pixel_width, pixel_height) = decoded.dimensions(); + let pixels = u64::from(pixel_width) * u64::from(pixel_height); + if pixel_width == 0 + || pixel_height == 0 + || pixel_width > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION + || pixel_height > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION + || pixels > PROJECT_MEDIA_PREVIEW_MAX_PIXELS + { + return Err("图像尺寸过大,无法在客户端预览".to_string()); + } + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(decoded) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|error| format!("图像容器转码失败:{relative_path}: {error}"))?; + Ok(TranscodedProjectArtContainer { + bytes: png, + media_type: "image/png", + pixel_width, + pixel_height, }) } @@ -430,6 +654,23 @@ fn detect_project_media_type( bytes: &[u8], kind: ProjectMediaPreviewKind, ) -> Result<&'static str, String> { + if kind == ProjectMediaPreviewKind::Model { + // 只按文件签名判定,不看扩展名:登记的是 `.glb` 却塞了别的内容时宁可报错, + // 也不要让渲染器去猜。 + if bytes.starts_with(b"glTF") { + return Ok("model/gltf-binary"); + } + if std::str::from_utf8(bytes).is_ok_and(|text| { + let trimmed = text.trim_start(); + trimmed.starts_with('{') && trimmed.contains("\"asset\"") + }) { + return Ok("model/gltf+json"); + } + if bytes.starts_with(b"Kaydara FBX Binary") || bytes.starts_with(b"; FBX") { + return Ok("application/octet-stream"); + } + return Err("模型预览只支持 GLB、glTF 或 FBX 文件".to_string()); + } if kind == ProjectMediaPreviewKind::Art && path_extension(path).as_deref() == Some("svg") { validate_safe_svg(bytes)?; return Ok("image/svg+xml"); @@ -623,7 +864,7 @@ mod tests { #[test] fn text_preview_requires_utf8_and_a_supported_extension() { - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); fs::create_dir_all(root.path().join("docs")).expect("docs dir"); fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown"); fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text"); @@ -645,7 +886,7 @@ mod tests { #[test] fn media_preview_accepts_safe_svg_and_rejects_active_svg() { - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); fs::create_dir_all(root.path().join("assets")).expect("assets dir"); fs::write( root.path().join("assets/icon.svg"), @@ -689,7 +930,7 @@ mod tests { #[test] fn media_preview_reports_safe_dimensions_for_extended_art_images() { - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); fs::create_dir_all(root.path().join("assets")).expect("assets dir"); let mut gif = b"GIF89a".to_vec(); @@ -735,12 +976,156 @@ mod tests { } } + /// 引擎图像容器(Cocos 的 `.tga` / `.tif` / `.hdr`)在浏览器里没有解码器: + /// 原生侧必须把它转成 PNG 再交给前端,并把真实像素尺寸一并带出去。 + #[test] + fn art_container_preview_transcodes_tga_to_png() { + // 用工程自带的临时目录助手:它会 canonicalize 并把目录 owner 归到当前用户, + // 否则 `%TEMP%` 下的临时目录在 Windows 上会被 owner 校验直接拒绝。 + let root = crate::tests::canonical_test_tempdir("engine-art-container-"); + fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + + let mut tga = vec![0_u8; 18]; + tga[2] = 2; // 未压缩真彩色 + tga[12..14].copy_from_slice(&2_u16.to_le_bytes()); // 宽 2 + tga[14..16].copy_from_slice(&2_u16.to_le_bytes()); // 高 2 + tga[16] = 24; // 24 位像素 + tga[17] = 0x20; // 左上角原点 + tga.extend_from_slice(&[ + 0, 0, 255, // BGR:红 + 0, 255, 0, // 绿 + 255, 0, 0, // 蓝 + 255, 255, 255, // 白 + ]); + fs::write(root.path().join("assets/grass.tga"), tga).expect("tga"); + + let preview = load_local_project_media_preview( + root.path(), + "assets/grass.tga", + ProjectMediaPreviewKind::Art, + ) + .expect("transcoded tga preview"); + assert_eq!(preview.media_type, "image/png"); + assert_eq!(preview.pixel_width, Some(2)); + assert_eq!(preview.pixel_height, Some(2)); + assert!(preview.data_url.starts_with("data:image/png;base64,")); + } + + /// 引擎序列化资源:UTF-8 的给正文(前端再抽结构摘要),非 UTF-8 的**降级**成 + /// `content: null`(卡面画类型卡),不是报错;未登记进白名单的扩展名一律拒绝。 + #[test] + fn structured_preview_reads_cocos_serialized_assets_and_degrades_binary() { + let root = crate::tests::canonical_test_tempdir("engine-structured-preview-"); + let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled(); + fs::create_dir_all(root.path().join("assets/anim")).expect("anim dir"); + fs::write( + root.path().join("assets/anim/walk.anim"), + "[{\"__type__\":\"cc.AnimationClip\",\"_duration\":1.25}]", + ) + .expect("animation clip"); + fs::write( + root.path().join("assets/anim/broken.pac"), + [0xff, 0xfe, 0x00], + ) + .expect("binary pac variant"); + fs::write( + root.path().join("assets/anim/glow.effect"), + "CCEffect %{\n techniques: []\n}", + ) + .expect("effect"); + + let clip = load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/walk.anim", + "application/json", + &cancellation, + ) + .expect("animation clip preview"); + assert_eq!(clip.media_type, "application/json"); + assert!(clip + .content + .as_deref() + .is_some_and(|content| content.contains("cc.AnimationClip"))); + + let effect = load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/glow.effect", + "text/plain", + &cancellation, + ) + .expect("effect preview"); + assert_eq!(effect.media_type, "text/plain"); + assert!(effect.content.is_some()); + + let degraded = load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/broken.pac", + "application/json", + &cancellation, + ) + .expect("binary variant degrades instead of failing"); + assert_eq!(degraded.content, None); + assert_eq!(degraded.byte_len, 3); + + assert!(load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/walk.anim", + "audio/mpeg", + &cancellation, + ) + .is_err()); + } + + /// 模型预览按**文件签名**判定媒体类型:GLB / glTF / FBX 各走各的,别的内容一律拒绝。 + #[test] + fn media_preview_detects_engine_model_media_types() { + let root = crate::tests::canonical_test_tempdir("engine-model-media-"); + fs::create_dir_all(root.path().join("assets/model")).expect("model dir"); + let mut glb = b"glTF".to_vec(); + glb.extend_from_slice(&2_u32.to_le_bytes()); + glb.extend_from_slice(&12_u32.to_le_bytes()); + fs::write(root.path().join("assets/model/hero.glb"), glb).expect("glb"); + fs::write( + root.path().join("assets/model/hero.gltf"), + "{\"asset\":{\"version\":\"2.0\"},\"meshes\":[]}", + ) + .expect("gltf"); + let mut fbx = b"Kaydara FBX Binary \x00".to_vec(); + fbx.extend_from_slice(&[0; 16]); + fs::write(root.path().join("assets/model/hero.fbx"), fbx).expect("fbx"); + fs::write(root.path().join("assets/model/broken.glb"), b"not-a-model") + .expect("broken model"); + + for (path, expected) in [ + ("assets/model/hero.glb", "model/gltf-binary"), + ("assets/model/hero.gltf", "model/gltf+json"), + ("assets/model/hero.fbx", "application/octet-stream"), + ] { + let preview = + load_local_project_media_preview(root.path(), path, ProjectMediaPreviewKind::Model) + .expect("model preview"); + assert_eq!(preview.media_type, expected, "{path}"); + assert!( + preview + .data_url + .starts_with(&format!("data:{expected};base64,")), + "{path}" + ); + } + assert!(load_local_project_media_preview( + root.path(), + "assets/model/broken.glb", + ProjectMediaPreviewKind::Model, + ) + .is_err()); + } + #[cfg(unix)] #[test] fn resource_preview_rejects_symlink_and_hardlink_files() { use std::os::unix::fs::symlink; - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); let outside = tempfile::tempdir().expect("outside"); fs::create_dir_all(root.path().join("docs")).expect("docs dir"); let source = outside.path().join("source.md"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 5d746c7cc..3fa66b97f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2989,6 +2989,65 @@ fn local_project_file_commands_read_write_list_and_delete_text_files() { fs::remove_dir_all(root).ok(); } +/// 引擎生成目录不进发现结果,但**只在当前目录确实是 Cocos Creator 工程时**生效: +/// 同名目录在别的工程里可能是真实源码目录,全局跳过会把用户代码从发现结果里删掉。 +#[test] +fn local_project_file_listing_skips_engine_generated_directories_only_for_engine_projects() { + let cocos = canonical_test_tempdir("engine-generated-dirs-"); + let cocos = cocos.path(); + fs::create_dir_all(cocos.join("assets")).expect("create assets"); + fs::create_dir_all(cocos.join("library/imported")).expect("create library"); + fs::create_dir_all(cocos.join("temp/programming")).expect("create temp"); + fs::create_dir_all(cocos.join("profiles/v2")).expect("create profiles"); + fs::create_dir_all(cocos.join("local")).expect("create local"); + fs::write(cocos.join("assets/hero.glb"), b"glTF").expect("write model"); + fs::write(cocos.join("library/imported/hero.json"), b"{}").expect("write library file"); + fs::write(cocos.join("temp/programming/packer.cpp"), b"//").expect("write temp file"); + fs::write(cocos.join("profiles/v2/user.json"), b"{}").expect("write profile file"); + fs::write(cocos.join("local/settings.json"), b"{}").expect("write local file"); + fs::write( + cocos.join("package.json"), + r#"{ "name": "cocos-project", "creator": { "version": "3.8.8" } }"#, + ) + .expect("write cocos package.json"); + + let listed = list_local_project_files_at(cocos).expect("list cocos project files"); + let paths = listed + .files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); + assert!(paths.contains(&"assets/hero.glb"), "{paths:?}"); + for skipped in [ + "library", + "library/imported/hero.json", + "temp", + "temp/programming/packer.cpp", + "profiles", + "profiles/v2/user.json", + "local", + "local/settings.json", + ] { + assert!( + !paths.contains(&skipped), + "引擎生成目录必须被过滤:{skipped} / {paths:?}" + ); + } + + let plain = canonical_test_tempdir("plain-project-dirs-"); + let plain = plain.path(); + fs::create_dir_all(plain.join("library")).expect("create plain library"); + fs::write(plain.join("library/index.ts"), b"//").expect("write plain library file"); + let listed = list_local_project_files_at(plain).expect("list plain project files"); + assert!( + listed + .files + .iter() + .any(|file| file.path == "library/index.ts"), + "非引擎工程的同名目录不得被过滤" + ); +} + #[test] fn local_project_export_package_uses_runtime_whitelist_and_records() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index 40de7843a..a7f485eaf 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -942,6 +942,94 @@ overscroll-behavior: contain; } +/* + * 引擎模型放大预览:与文档预览同一套浮层口径(宽度 / 圆角 / 头尾结构), + * 只有主体换成交互式三维视口 —— 视口自己吃掉指针与滚轮事件。 + */ +.game-resource-model-preview { + display: flex; + flex-direction: column; + width: min(1040px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + min-width: 0; + border: 1px solid var(--platform-surface-border); + border-radius: 16px; + overflow: hidden; +} + +.game-resource-model-preview__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--platform-surface-border); +} + +.game-resource-model-preview__header strong { + min-width: 0; + overflow-wrap: anywhere; +} + +.game-resource-model-preview__actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; +} + +.game-resource-model-preview__body { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + min-width: 0; + padding: 16px 20px 20px; +} + +.game-resource-model-preview__hint { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + color: var(--platform-warm-text, #8b5b45); + font-size: 12px; +} + +.game-resource-model-viewer { + position: relative; + flex: 1 1 auto; + min-height: min(62dvh, 560px); + border: 1px solid var(--platform-surface-border); + border-radius: 12px; + background: linear-gradient(145deg, #f7f2ec, #e6ddd2); + overflow: hidden; + /* 视口内的指针手势一律给三维视角,不冒泡去拖动画布。 */ + touch-action: none; +} + +.game-resource-model-viewer__canvas { + display: block; + width: 100%; + height: 100%; + cursor: grab; +} + +.game-resource-model-viewer__canvas:active { + cursor: grabbing; +} + +.game-resource-model-viewer__notice { + display: grid; + height: 100%; + margin: 0; + padding: 24px; + place-items: center; + color: #8b5b45; + font-size: 13px; + text-align: center; +} + /* 资源面板:独立浮层面板(预览 / 上传 / 下载 / 多选)。 */ .game-resource-panel { width: min(880px, calc(100% - 32px)); diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 613ec51e4..c5fa3bde4 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -7326,7 +7326,16 @@ iframe.preview-frame { min-height: var(--resource-card-height); padding: 0; overflow: visible; - border: 0; + /* + * 边框**常驻 1px 透明**,状态只改颜色。 + * + * 卡片是 `box-sizing: border-box`,且卡面(`.game-resource-card-visual`)与角标都是 + * 绝对定位、以 **padding box** 为包含块:底态若是 `border: 0`,悬停 / 选中时才出现的 + * 1px 边框会把内容盒四边各吃掉 1px,卡面与角标当场位移并缩小 2px —— 用户看到的就是 + * 「一悬浮,卡片里面的东西跟着动」。常驻透明边框让所有状态的 padding box 完全一致, + * 外尺寸仍然等于 `--resource-card-width/height`(border-box),画布坐标不受影响。 + */ + border: 1px solid transparent; border-radius: 12px; background: #fff; color: #4e382f; @@ -7348,9 +7357,8 @@ iframe.preview-frame { 0 0 0 2px rgb(216 115 66 / 24%); } -/* 卡片本体是 `border: 0`(下面那条基规则)。`border-color` 单独写没有意义——0 宽的边框 - 画不出颜色,所以选中 / 悬停 / 聚焦都必须写成完整的 `border`,否则这三个状态在视觉上 - 完全看不出来。宽度与圆角保持 1px / 12px,`box-sizing: border-box` 下不会改变卡片尺寸。 */ +/* 基态已经是 1px 透明边框,这里只把颜色点亮;宽度与圆角保持 1px / 12px, + padding box 不变,因此卡面与角标在状态切换时不会位移。 */ .game-resource-card:hover, .game-resource-card:focus-within, .game-resource-card.is-selected { @@ -7686,6 +7694,96 @@ iframe.preview-frame { gap: 10px; } +/* + * 引擎资源卡(引擎三维模型 / 序列化资源 / 无法解码的容器)。 + * + * 三者的共同点:卡面要么是渲染出来的缩略图,要么是「类型 + 扩展名」的类型卡, + * 都必须与既有卡片同族 —— 因此沿用同一套浅色渐变底与居中排布,不新造一套视觉语言。 + */ +.game-resource-card-model-visual { + /* + * 绝对定位铺满卡面:`width/height: 100%` 在 `place-items: center` 的网格里会因为 + * 行高不确定而退化成"按内容定高",缩略图就以自然比例溢出卡面被裁掉(看起来像被拉伸)。 + * 用 `inset: 0` 把盒子定死,图片才能在固定框里做 letterbox。 + */ + position: absolute; + inset: 0; + display: grid; + width: 100%; + height: 100%; + background: linear-gradient(145deg, #f7f2ec, #e6ddd2); + place-items: center; +} + +.game-resource-card-model-visual img { + /* + * 图片绝对定位铺满宿主:宿主是 `place-items: center` 的网格,网格项的高度会退化成 + * "按内容定高"(`height: 100%` 解析成 auto),缩略图就会按自身比例长过卡片并被裁掉。 + * 铺满 + `object-fit: contain` 才是「不拉伸、不裁切」的口径。 + */ + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; +} + +.game-resource-card-engine-visual { + position: absolute; + inset: 0; + display: grid; + gap: 8px; + width: 100%; + height: 100%; + padding: 12px; + background: linear-gradient(145deg, #f7f2ec, #e6ddd2); + color: #8b5b45; + align-content: center; + place-items: center; +} + +.game-resource-card-engine-label { + max-width: 100%; + padding: 1px 7px; + border: 1px solid rgb(139 91 69 / 28%); + border-radius: 999px; + background: rgb(255 255 255 / 72%); + color: #8b5b45; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + text-align: center; + word-break: break-word; +} + +/* 引擎序列化资源:结构摘要按"最多三行"截断,与文档卡的卡面口径一致。 */ +.game-resource-card-structured-visual { + position: absolute; + inset: 0; + display: grid; + gap: 8px; + width: 100%; + height: 100%; + padding: 14px; + background: linear-gradient(145deg, #fff8f1, #f2ded2); + color: #8b5b45; + align-content: center; + place-items: center; +} + +.game-resource-card-structured-text { + display: -webkit-box; + max-height: 100%; + overflow: hidden; + color: #674c41; + font-size: 11px; + line-height: 1.55; + text-align: left; + word-break: break-word; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + .game-resource-card-version-relations { color: #8e6f62; font-size: 9px; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx new file mode 100644 index 000000000..189d0f8ba --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from 'react'; + +import { renderResourceModelThumbnail } from './resourceModelThumbnail'; + +type ResourceModelPreviewProps = { + /** 预览管线给出的模型字节 URL(blob URL)。 */ + sourceUrl: string; + /** 原生侧判定的媒体类型(`model/gltf-binary` / `model/gltf+json` / FBX 的 octet-stream)。 */ + mediaType: string; + /** 缩略图缓存身份(见 `resourceModelThumbnailIdentity`):blob URL 会变,身份不会。 */ + identity: string; + /** 渲染不出来时画在同一个位置上的类型卡文案(例如「模型 · GLB」)。 */ + fallbackLabel: string; +}; + +/** + * 缩略图超采样倍数。 + * + * 资源画布用 `transform: scale(--resource-section-zoom)` 放大栏目视图(真机 1.5 倍),而 + * `ResizeObserver` 与 `offsetWidth` 都只看**布局盒**:按布局尺寸 1:1 渲染出来的图,被画布 + * 放大后就是糊的。按 2 倍超采样既有余量覆盖画布缩放,也不至于让单张缩略图失控。 + */ +const RESOURCE_MODEL_THUMBNAIL_SUPERSAMPLE = 2; +const RESOURCE_MODEL_THUMBNAIL_MAX_EDGE = 1024; + +/** + * 资源卡上的引擎模型缩略图。 + * + * 这一层只关心「把渲染结果画出来」,渲染本身在 `resourceModelThumbnail` 的单例队列里 + * (整页只有一个 WebGL 上下文)。渲染失败**不算预览失败**:卡面就地降级成类型卡, + * 预览管线里已经读到的字节与状态保持不变,排障信息通过 + * `data-model-preview-status` 暴露在 DOM 上。 + */ +export function ResourceModelPreview({ + sourceUrl, + mediaType, + identity, + fallbackLabel, +}: ResourceModelPreviewProps) { + const hostRef = useRef(null); + const [thumbnail, setThumbnail] = useState(null); + const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>( + 'loading', + ); + /** + * 渲染尺寸按**宿主真实几何**(含画布缩放后的视觉尺寸)取,并且跟着宿主的尺寸变化重渲。 + * + * 卡片在总览小图与栏目大图里是同一个组件:只在挂载时量一次,会拿到当时那个更小的盒子, + * 之后卡片放大也不会重画 —— 表现为缩略图被放大到发虚、比例与卡面不一致。 + */ + const [renderSize, setRenderSize] = useState<{ + width: number; + height: number; + } | null>(null); + + useEffect(() => { + const host = hostRef.current; + if (!host) { + return undefined; + } + const measure = () => { + /* + * 取**布局尺寸**(`offsetWidth` 不受祖先 transform 影响)而不是 `getBoundingClientRect`: + * 画布缩放是 transform,像素值会随缩放变化;布局尺寸才是稳定的口径,再乘超采样倍数。 + */ + const width = Math.min( + RESOURCE_MODEL_THUMBNAIL_MAX_EDGE, + Math.max( + 192, + Math.round( + (host.offsetWidth || 320) * RESOURCE_MODEL_THUMBNAIL_SUPERSAMPLE, + ), + ), + ); + const height = Math.min( + RESOURCE_MODEL_THUMBNAIL_MAX_EDGE, + Math.max( + 144, + Math.round( + (host.offsetHeight || 240) * RESOURCE_MODEL_THUMBNAIL_SUPERSAMPLE, + ), + ), + ); + setRenderSize((current) => + current && current.width === width && current.height === height + ? current + : { width, height }, + ); + }; + measure(); + // 测试用的 jsdom 没有 ResizeObserver:没有它就退化成"只在挂载时量一次"。 + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + const observer = new ResizeObserver(measure); + observer.observe(host); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!renderSize) { + return undefined; + } + let disposed = false; + setThumbnail(null); + setStatus('loading'); + const request = { + sourceUrl, + mediaType, + identity, + width: renderSize.width, + height: renderSize.height, + }; + void renderResourceModelThumbnail(request) + .then((dataUrl) => { + if (disposed) { + return; + } + setThumbnail(dataUrl); + setStatus('ready'); + }) + .catch(() => { + if (!disposed) { + setStatus('failed'); + } + }); + return () => { + disposed = true; + }; + }, [identity, mediaType, renderSize, sourceUrl]); + + return ( + + {thumbnail ? ( + + ) : ( + {fallbackLabel} + )} + + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreviewDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreviewDialog.tsx new file mode 100644 index 000000000..503ccaed8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreviewDialog.tsx @@ -0,0 +1,114 @@ +import { Maximize2, RotateCcw, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; +import { PlatformIconButton } from '../../../../../packages/shared/src/components/PlatformIconButton'; +import { ThemedModal } from '../../components/modal/ThemedModal'; +import type { ProjectResourceCardPreviewState } from './resourceCardPreviewModel'; +import { + ResourceModelViewer, + type ResourceModelViewerHandle, +} from './ResourceModelViewer'; +import type { ProjectResource } from './resourceProjectionModel'; + +/** + * 引擎模型的**放大预览浮层**:在独立浮层里给出与 3D 建模软件一致的视角操作。 + * + * 卡片上仍然是静态缩略图(一张画布上十几张模型卡共用缩略图那一个 WebGL 上下文); + * 只有打开这个浮层时才会新建一个交互式上下文,关闭即释放。浮层内的指针与滚轮事件由 + * three 的 OrbitControls 独占:左键旋转、右键/中键平移、滚轮推拉。 + */ +export function ResourceModelPreviewDialog({ + resource, + identity, + preview, + onRequestPreview, + onClose, +}: { + resource: ProjectResource; + identity: string; + preview: ProjectResourceCardPreviewState; + onRequestPreview: ( + resource: ProjectResource, + identity: string, + reason: 'detail', + ) => void; + onClose: () => void; +}) { + const [resetSignal, setResetSignal] = useState(0); + const viewerHandleRef = useRef(null); + + useEffect(() => { + onRequestPreview(resource, identity, 'detail'); + }, [resource, identity, onRequestPreview]); + + const handleReady = useCallback((handle: ResourceModelViewerHandle) => { + viewerHandleRef.current = handle; + }, []); + + const sourceUrl = + preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null; + + return ( + +
+ {resource.label} +
+ setResetSignal((current) => current + 1)} + > + +
+
+
+ {preview.status === 'failed' ? ( + <> +

{preview.error}

+ {preview.retryable ? ( + onRequestPreview(resource, identity, 'detail')} + > + 重试 + + ) : null} + + ) : preview.status === 'loaded' && !sourceUrl ? ( +

没有可预览的模型内容

+ ) : sourceUrl ? ( + + ) : ( +

正在加载模型…

+ )} +

+

+
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx new file mode 100644 index 000000000..4b06525cc --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx @@ -0,0 +1,192 @@ +import { useEffect, useRef, useState } from 'react'; + +import { + addResourceModelLights, + disposeResourceModelObject, + frameResourceModelInCamera, + loadResourceModelObject, + type ResourceModelSource, +} from './resourceModelScene'; + +export type ResourceModelViewerHandle = { + /** 复位视角(回到打开时的取景);供浮层上的「复位视角」按钮调用。 */ + resetView: () => void; +}; + +type ResourceModelViewerProps = { + source: ResourceModelSource; + /** 复位视角按钮的点击计数:变化即复位,避免把 three 对象提到 React 状态里。 */ + resetSignal: number; + onReady: (handle: ResourceModelViewerHandle) => void; +}; + +/** + * 交互式模型预览画布:视角操作与 3D 建模软件一致。 + * + * - 左键拖拽:旋转(Orbit) + * - 右键 / 中键拖拽:平移(Pan) + * - 滚轮:推拉(Zoom) + * + * 与缩略图共用 `resourceModelScene` 的加载 / 取景 / 释放口径,所以「卡片上看得见、放大后 + * 看不见」这类分叉不会出现。渲染器是本浮层自己的 WebGL 上下文,关闭时立即 `dispose`: + * 资源画布上十几张模型卡仍然只共用缩略图那一个上下文(见 `resourceModelThumbnail`)。 + */ +export function ResourceModelViewer({ + source, + resetSignal, + onReady, +}: ResourceModelViewerProps) { + const hostRef = useRef(null); + const resetRef = useRef<(() => void) | null>(null); + /** + * 用 ref 保存 source:调用方通常直接传对象字面量,把它放进依赖数组会让模型每次渲染 + * 都重建一次 WebGL 上下文;真正的重建条件只有「换了模型」。 + */ + const sourceRef = useRef(source); + sourceRef.current = source; + const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>( + 'loading', + ); + const [error, setError] = useState(''); + + useEffect(() => { + let disposed = false; + let cleanup: (() => void) | undefined; + setStatus('loading'); + setError(''); + const host = hostRef.current; + if (!host) { + return undefined; + } + void (async () => { + try { + const THREE = await import('three'); + const { OrbitControls } = await import( + 'three/examples/jsm/controls/OrbitControls.js' + ); + if (disposed) { + return; + } + const width = Math.max(240, Math.round(host.clientWidth)); + const height = Math.max(200, Math.round(host.clientHeight)); + const modelSource = { + sourceUrl: sourceRef.current.sourceUrl, + mediaType: sourceRef.current.mediaType, + }; + const renderer = new THREE.WebGLRenderer({ + alpha: true, + antialias: true, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.setSize(width, height, false); + renderer.domElement.className = 'game-resource-model-viewer__canvas'; + host.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + addResourceModelLights(THREE, scene); + const camera = new THREE.PerspectiveCamera( + 35, + width / height, + 0.01, + 10_000, + ); + + const controls = new OrbitControls(camera, renderer.domElement); + // 阻尼让拖动有惯性,手感与建模软件一致;它要求每帧 update,因此下面常驻 rAF。 + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.screenSpacePanning = true; + controls.zoomSpeed = 0.9; + // 允许贴到模型内部看细节,但不允许转到背面时被裁剪面切掉模型。 + controls.minDistance = 0.01; + controls.maxDistance = Number.POSITIVE_INFINITY; + + const object = await loadResourceModelObject(modelSource); + if (disposed) { + disposeResourceModelObject(object); + renderer.dispose(); + renderer.domElement.remove(); + return; + } + scene.add(object); + const frame = () => { + const { center } = frameResourceModelInCamera(THREE, object, camera); + // 轨道中心 = 模型包围盒中心:缩放与旋转都绕着模型本身,和建模软件的取景一致。 + controls.target.copy(center); + controls.update(); + }; + frame(); + controls.update(); + + const resizeObserver = new ResizeObserver(() => { + const nextWidth = Math.max(240, Math.round(host.clientWidth)); + const nextHeight = Math.max(200, Math.round(host.clientHeight)); + camera.aspect = nextWidth / nextHeight; + camera.updateProjectionMatrix(); + renderer.setSize(nextWidth, nextHeight, false); + }); + resizeObserver.observe(host); + + let frameHandle = 0; + const renderLoop = () => { + controls.update(); + renderer.render(scene, camera); + frameHandle = window.requestAnimationFrame(renderLoop); + }; + frameHandle = window.requestAnimationFrame(renderLoop); + + resetRef.current = () => { + frame(); + }; + onReady({ resetView: () => resetRef.current?.() }); + setStatus('ready'); + + cleanup = () => { + window.cancelAnimationFrame(frameHandle); + resizeObserver.disconnect(); + controls.dispose(); + scene.remove(object); + disposeResourceModelObject(object); + renderer.dispose(); + renderer.domElement.remove(); + resetRef.current = null; + }; + } catch (viewerError) { + if (!disposed) { + setStatus('failed'); + setError( + viewerError instanceof Error + ? viewerError.message + : String(viewerError), + ); + } + } + })(); + return () => { + disposed = true; + cleanup?.(); + }; + }, [onReady, source.mediaType, source.sourceUrl]); + + useEffect(() => { + if (resetSignal > 0) { + resetRef.current?.(); + } + }, [resetSignal]); + + return ( +
+ {status === 'ready' ? null : ( +

+ {status === 'loading' + ? '正在加载模型…' + : `当前环境无法渲染三维预览${error ? `:${error}` : ''}`} +

+ )} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx index 281ce517c..7001825a5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx @@ -12,8 +12,13 @@ import { type ProjectResourceCardPreviewState, projectResourceCardPreviewVariant, projectResourceJsonPresentation, + projectResourcePathExtension, + projectResourceStructuredPreviewText, } from './resourceCardPreviewModel'; +import { ResourceModelPreview } from './ResourceModelPreview'; +import { resourceModelThumbnailIdentity } from './resourceModelThumbnail'; import type { ProjectResource } from './resourceProjectionModel'; +import { projectResourceTypeLabel } from './resourceProjectionModel'; type ResourcePreviewMediaProps = { /** @@ -96,7 +101,59 @@ export function ResourcePreviewMedia({ : null; const sourceUrl = preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null; + const engineBadgeLabel = (() => { + if (!resource) { + return '引擎资源'; + } + const extension = projectResourcePathExtension(resource.path); + const typeLabel = projectResourceTypeLabel(resource); + return extension ? `${typeLabel} · ${extension.toUpperCase()}` : typeLabel; + })(); const visual = (() => { + // 引擎资源的三个分支与资源卡同源(判据都来自 `projectResourceCardPreviewKind`), + // 差别只在尺寸:面板里的缩略图更大,因此模型缩略图按面板几何渲染。 + if (kind === 'model') { + return sourceUrl ? ( + + ) : ( + + {engineBadgeLabel} + + ); + } + if (kind === 'structured' || kind === 'binary') { + const structuredText = + kind === 'structured' + ? projectResourceStructuredPreviewText( + preview.status === 'loaded' ? preview.preview.content : undefined, + ) + : ''; + return structuredText ? ( + + {structuredText} + + ) : ( + + {engineBadgeLabel} + + ); + } const jsonPresentation = resource ? projectResourceJsonPresentation(resource, preview) : null; 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 e660c2c73..fec1f8dc6 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 @@ -17,6 +17,7 @@ import { import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { AtSign, + Box, Crosshair, Eye, FileCode2, @@ -299,6 +300,8 @@ import { projectResourceCodeTypeLabel, projectResourceDocumentPreviewText, projectResourceJsonPresentation, + projectResourcePathExtension, + projectResourceStructuredPreviewText, } from './resourceCardPreviewModel'; import { ResourceClassificationPanel } from './ResourceClassificationPanel'; import { @@ -331,6 +334,9 @@ import { ResourceInfoFieldsView, ResourceInfoPanelView, } from './ResourceInfoPanelView'; +import { ResourceModelPreview } from './ResourceModelPreview'; +import { ResourceModelPreviewDialog } from './ResourceModelPreviewDialog'; +import { resourceModelThumbnailIdentity } from './resourceModelThumbnail'; import { ResourcePreviewMedia } from './ResourcePreviewMedia'; import { type ProjectAgentResultSummary, @@ -803,6 +809,25 @@ const ResourceCard = memo(function ResourceCard({ previewVariant === 'markdown', ) : ''; + /** + * 引擎资源的卡面口径。 + * + * - `engineTypeLabel`:模型 / 动画 / 材质 这类**引擎语义**的类型名(按扩展名判定, + * 不复用"图片 / 文档"的通用角标); + * - `structuredPreviewText`:场景、预制体、动画剪辑等序列化资源的结构摘要;原生判定为 + * 二进制变体时(`content === undefined`)为空串,卡面画类型卡而不是报错。 + */ + const engineTypeLabel = projectResourceTypeLabel(resource); + const engineExtension = projectResourcePathExtension(resource.path); + const engineBadgeLabel = engineExtension + ? `${engineTypeLabel} · ${engineExtension.toUpperCase()}` + : engineTypeLabel; + const structuredPreviewText = + kind === 'structured' + ? projectResourceStructuredPreviewText( + preview.status === 'loaded' ? preview.preview.content : undefined, + ) + : ''; useEffect(() => { const element = cardRef.current; @@ -832,6 +857,66 @@ const ResourceCard = memo(function ResourceCard({ }, [previewIdentity, sourceUrl]); const visual = (() => { + /* + * 引擎资源三个分支排在图片 / 视频 / 音频之前: + * - `model`:交给单例模型渲染器出缩略图,渲染不出来就地降级成类型卡; + * - `structured`:显示结构摘要(场景节点数、动画时长、材质类型…),没有可读正文时同样是类型卡; + * - `binary`:客户端解不了的容器,直接画类型卡,不读字节。 + */ + if (kind === 'model') { + return sourceUrl ? ( + + ) : ( + + + ); + } + if (kind === 'structured') { + return structuredPreviewText ? ( + + + ) : ( + + + ); + } + if (kind === 'binary') { + return ( + + + ); + } if (jsonPresentation) { return ( @@ -1614,6 +1699,14 @@ export default function ProjectDevelopmentView({ const [resourcePanelOpen, setResourcePanelOpen] = useState(false); const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] = useState(null); + /** + * 引擎模型的放大预览浮层身份(同时是当前选中资源的预览身份)。 + * + * 与文档预览浮层一样只在 `resources` 视图、且身份仍等于当前选中资源时渲染; + * 它是**只读预览**,不参与编辑、不写回 manifest。 + */ + const [resourceModelPreviewIdentity, setResourceModelPreviewIdentity] = + useState(null); /** * 「生成素材」浮层的本次放行类型。 * @@ -6423,10 +6516,18 @@ export default function ProjectDevelopmentView({ ) { setResourceDocumentPreviewIdentity(null); } + if ( + mode !== 'resources' || + uiEditorRoute || + resourceModelPreviewIdentity !== selectedResourcePreviewIdentity + ) { + setResourceModelPreviewIdentity(null); + } }, [ mode, uiEditorRoute, resourceDocumentPreviewIdentity, + resourceModelPreviewIdentity, selectedResourcePreviewIdentity, ]); @@ -6436,6 +6537,12 @@ export default function ProjectDevelopmentView({ return () => protectResourceCardPreview(null); }, [protectResourceCardPreview, resourceDocumentPreviewIdentity]); + useEffect(() => { + if (!resourceModelPreviewIdentity) return; + protectResourceCardPreview(resourceModelPreviewIdentity); + return () => protectResourceCardPreview(null); + }, [protectResourceCardPreview, resourceModelPreviewIdentity]); + /** * 解析一次资源派生的源身份:取项目 revision,必要时把任务产物正规化成正式素材。 * @@ -8002,6 +8109,36 @@ export default function ProjectDevelopmentView({ 预览 ) : null} + {/* + * 引擎模型:进「放大预览」浮层做交互式视角操作。 + * 判据与卡面同源(`projectResourceCardPreviewKind`), + * 因此不会出现「卡片是模型卡、却没有 3D 入口」的分叉。 + */} + {selectedResource && + selectedResourcePreviewIdentity && + projectResourceCardPreviewKind( + selectedResource, + ) === 'model' ? ( + } + onClick={() => { + stopActiveCardMedia(); + resourceCardPreviews.requestPreview( + selectedResource, + selectedResourcePreviewIdentity, + 'detail', + ); + setResourceModelPreviewIdentity( + selectedResourcePreviewIdentity, + ); + }} + > + 3D 预览 + + ) : null} {selectedResource && selectedResourceOpensUiEditor ? ( setResourceDocumentPreviewIdentity(null)} /> ) : null} + {mode === 'resources' && + !uiEditorRoute && + selectedResource && + resourceModelPreviewIdentity && + resourceModelPreviewIdentity === selectedResourcePreviewIdentity ? ( + setResourceModelPreviewIdentity(null)} + /> + ) : null} {resourcePanelOpen ? ( => + typeof entry === 'object' && entry !== null && !Array.isArray(entry), + ); + const typeCounts = new Map(); + for (const entry of entries) { + const type = entry['__type__']; + if (typeof type === 'string' && type) { + typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1); + } + } + const rootType = entries + .map((entry) => entry['__type__']) + .find( + (type): type is string => typeof type === 'string' && type.length > 0, + ); + if (!rootType) { + return null; + } + const parts: string[] = [rootType]; + const nodeCount = typeCounts.get('cc.Node') ?? 0; + if (nodeCount > 0) { + parts.push(`${nodeCount} 个节点`); + } + const duration = entries + .map((entry) => entry['_duration']) + .find((value): value is number => typeof value === 'number' && value > 0); + if (duration !== undefined) { + parts.push(`${duration.toFixed(2)} 秒`); + } + const name = entries + .map((entry) => entry['_name']) + .find( + (value): value is string => typeof value === 'string' && value.length > 0, + ); + if (name) { + parts.push(name); + } + const assetReferences = entries.filter( + (entry) => entry['__uuid__'] !== undefined, + ).length; + if (assetReferences > 0) { + parts.push(`${assetReferences} 处资源引用`); + } + parts.push(`${typeCounts.size} 种类型`); + return parts.join(' · '); +} + +/** + * 引擎序列化资源的卡面预览文本:优先结构摘要,其次前几行正文。 + * + * `content` 为 `undefined`(原生读取判定的二进制变体)时返回空串,卡面画类型卡。 + */ +export function projectResourceStructuredPreviewText( + content: string | undefined, +): string { + if (content === undefined) { + return ''; + } + return ( + projectResourceCocosStructureSummary(content) ?? + projectResourceDocumentPreviewText(content, false) + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts new file mode 100644 index 000000000..6d74551b8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts @@ -0,0 +1,118 @@ +import type * as ThreeTypes from 'three'; + +/** + * 引擎三维模型的**共用场景能力**:加载、取景、释放。 + * + * 卡片缩略图(`resourceModelThumbnail`)和交互式预览浮层(`ResourceModelViewer`)都走这里, + * 避免出现「缩略图能打开、放大后打不开」这种两套加载逻辑的分叉 —— 格式支持面、取景算法和 + * 释放口径必须逐字一致。 + */ +export type ResourceModelSource = { + /** 预览管线给出的 blob URL(模型整份字节)。 */ + sourceUrl: string; + /** `model/gltf-binary`、`model/gltf+json` 或 `application/octet-stream`(FBX)。 */ + mediaType: string; +}; + +export function isResourceModelFbx(source: ResourceModelSource) { + return ( + source.mediaType === 'application/octet-stream' || + source.sourceUrl.toLowerCase().includes('.fbx') + ); +} + +/** + * 按媒体类型加载模型。 + * + * 只支持**自包含**的模型:`.glb`、单文件 `.gltf`(buffer 内嵌)、`.fbx`。多文件 glTF + * (`baseURI` 指向外部 `.bin` / 贴图)在 blob URL 下无法解析相对路径,加载失败由调用方 + * 降级成类型卡,不做静默半渲染。 + */ +export async function loadResourceModelObject( + source: ResourceModelSource, +): Promise { + if (isResourceModelFbx(source)) { + const { FBXLoader } = await import( + 'three/examples/jsm/loaders/FBXLoader.js' + ); + return new FBXLoader().loadAsync(source.sourceUrl); + } + const { GLTFLoader } = await import( + 'three/examples/jsm/loaders/GLTFLoader.js' + ); + const gltf = await new GLTFLoader().loadAsync(source.sourceUrl); + if (!gltf.scene) { + throw new Error('模型内容为空'); + } + return gltf.scene; +} + +/** + * 把相机摆到能完整看见整个模型的位置,并返回模型中心与尺寸。 + * + * 取景口径与 3D 软件一致:按包围盒最大边计算距离,留 35% 余量,从右上前方俯视。 + * 缩略图与交互预览共用同一条公式,尺寸窗口不同也不会出现「一边看得见、一边看不见」。 + */ +export function frameResourceModelInCamera( + THREE: typeof ThreeTypes, + object: ThreeTypes.Object3D, + camera: ThreeTypes.PerspectiveCamera, +) { + const box = new THREE.Box3().setFromObject(object); + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + const maxDimension = Math.max(size.x, size.y, size.z); + if (!Number.isFinite(maxDimension) || maxDimension <= 0) { + throw new Error('模型几何尺寸无效'); + } + const distance = + (maxDimension / 2 / Math.tan((camera.fov * Math.PI) / 360)) * 1.35; + camera.position.set( + center.x + distance * 0.55, + center.y + distance * 0.42, + center.z + distance * 0.75, + ); + camera.near = Math.max(distance / 500, 0.001); + camera.far = distance * 20; + camera.lookAt(center); + camera.updateProjectionMatrix(); + return { center, size, maxDimension, distance }; +} + +/** 环境光照:半球光 + 一盏主光,保证没有材质贴图的模型也有体积感。 */ +export function addResourceModelLights( + THREE: typeof ThreeTypes, + scene: ThreeTypes.Scene, +) { + scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 2.2)); + const keyLight = new THREE.DirectionalLight(0xffffff, 2.0); + keyLight.position.set(2, 3, 4); + scene.add(keyLight); +} + +/** 释放模型对象占用的几何、材质与贴图,避免反复开关预览时显存只涨不降。 */ +export function disposeResourceModelObject(object: ThreeTypes.Object3D) { + object.traverse((child) => { + const mesh = child as ThreeTypes.Mesh; + mesh.geometry?.dispose?.(); + const material = mesh.material; + const materials = Array.isArray(material) + ? material + : material + ? [material] + : []; + for (const entry of materials) { + for (const value of Object.values( + entry as unknown as Record, + )) { + const texture = value as + | { isTexture?: boolean; dispose?: () => void } + | undefined; + if (texture?.isTexture && typeof texture.dispose === 'function') { + texture.dispose(); + } + } + (entry as { dispose?: () => void }).dispose?.(); + } + }); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts new file mode 100644 index 000000000..4ab1e0ecd --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts @@ -0,0 +1,164 @@ +import type * as ThreeTypes from 'three'; + +import { + addResourceModelLights, + disposeResourceModelObject, + frameResourceModelInCamera, + loadResourceModelObject, +} from './resourceModelScene'; + +/** + * 引擎三维模型缩略图渲染器(模块级单例)。 + * + * 为什么是单例:一张资源画布上可能同时停着十几张模型卡,而浏览器能给一个页面的 + * WebGL 上下文是**有上限**的(超出后最早的上下文会被丢弃,表现为"有些卡片莫名其妙变白")。 + * 这里只保留**一个**离屏渲染器 + 一个串行队列:谁需要缩略图谁排队,渲染完把像素画进 + * 卡片自己的 2D canvas。因此无论画布上有多少张模型卡,WebGL 上下文始终只有 1 个。 + * + * 渲染是**一次性**的静态帧(不跑动画循环):卡片只是缩略图,让十几张卡各自跑一个 + * requestAnimationFrame 循环会白烧 GPU 和电量。 + */ +export type ResourceModelThumbnailRequest = { + /** 预览管线给出的 blob URL(模型整份字节)。 */ + sourceUrl: string; + /** `model/gltf-binary`、`model/gltf+json` 或 `application/octet-stream`(FBX)。 */ + mediaType: string; + width: number; + height: number; + /** + * 缩略图的**稳定身份**(资源身份 + 路径 + 字节数)。 + * + * blob URL 每次读取都会变,拿它当缓存键会让「预览缓存淘汰后重读同一个模型」变成 + * 一次重新解析 + 重新渲染;用稳定身份作键,重读只会重新取字节,缩略图直接命中缓存。 + */ + identity: string; +}; + +const THUMBNAIL_CACHE_LIMIT = 24; +const thumbnailCache = new Map(); + +type RendererBundle = { + renderer: ThreeTypes.WebGLRenderer; + scene: ThreeTypes.Scene; + camera: ThreeTypes.PerspectiveCamera; +}; + +let rendererPromise: Promise | null = null; +let renderQueue: Promise = Promise.resolve(); + +export function resourceModelThumbnailCacheSize() { + return thumbnailCache.size; +} + +/** + * 缩略图缓存键:**不含 blob URL**。 + * + * 预览缓存淘汰后同一份模型会被重新读取、拿到新的 blob URL;若把 URL 放进键里, + * 每一次重读都会变成一次重新解析 + 重新渲染。用稳定身份作键,重读只会重新取字节。 + */ +export function resourceModelThumbnailCacheKey( + request: Pick< + ResourceModelThumbnailRequest, + 'mediaType' | 'identity' | 'width' | 'height' + >, +) { + return `${request.mediaType}|${request.identity}|${request.width}x${request.height}`; +} + +export function resourceModelThumbnailIdentity(input: { + resourceKey: string; + path: string; + byteLen?: number; +}) { + return `${input.resourceKey}|${input.path}|${input.byteLen ?? 0}`; +} + +function rememberThumbnail(cacheKey: string, dataUrl: string) { + thumbnailCache.delete(cacheKey); + thumbnailCache.set(cacheKey, dataUrl); + while (thumbnailCache.size > THUMBNAIL_CACHE_LIMIT) { + const oldest = thumbnailCache.keys().next().value; + if (oldest === undefined) { + break; + } + thumbnailCache.delete(oldest); + } +} + +async function createRendererBundle(): Promise { + // 动态导入:三维渲染器只在真的出现模型卡时才加载,不进主包、不影响其它卡片的启动成本。 + const THREE = await import('three'); + const canvas = document.createElement('canvas'); + const renderer = new THREE.WebGLRenderer({ + canvas, + alpha: true, + antialias: true, + // 没有它,`toDataURL` 拿到的可能是被清空的缓冲(否则渲染后立刻被交换掉)。 + preserveDrawingBuffer: true, + }); + renderer.setClearColor(0x000000, 0); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 10_000); + addResourceModelLights(THREE, scene); + return { renderer, scene, camera }; +} + +function rendererBundle() { + rendererPromise ??= createRendererBundle().catch((error: unknown) => { + // 失败不缓存:下一次卡片进入视口时还有机会(例如 WebGL 上下文被临时耗尽)。 + rendererPromise = null; + throw error; + }); + return rendererPromise; +} + +async function renderOnce( + request: ResourceModelThumbnailRequest, +): Promise { + const THREE = await import('three'); + const { renderer, scene, camera } = await rendererBundle(); + const width = Math.max(64, Math.round(request.width)); + const height = Math.max(64, Math.round(request.height)); + const object = await loadResourceModelObject(request); + try { + scene.add(object); + /** + * 相机宽高比必须跟着这次渲染的像素尺寸走。 + * + * 这个相机是单例复用件(默认 `aspect = 1`),而卡片是宽扁的:不更新宽高比时, + * 方形投影会被塞进非方形的绘制缓冲,模型在卡面上就是**被拉伸**的 —— 与 3D 软件里 + * 同一个模型的比例对不上。浮层里的交互相机已经在创建 / 改尺寸时设过,这里补齐缩略图。 + */ + camera.aspect = width / height; + frameResourceModelInCamera(THREE, object, camera); + renderer.setPixelRatio(1); + renderer.setSize(width, height, false); + renderer.render(scene, camera); + return renderer.domElement.toDataURL('image/png'); + } finally { + scene.remove(object); + disposeResourceModelObject(object); + } +} + +/** + * 取一张模型缩略图(同一份字节只渲染一次)。 + * + * 队列是串行的:渲染器只有一个,两个模型同时渲染会互相覆盖同一个 canvas。 + */ +export async function renderResourceModelThumbnail( + request: ResourceModelThumbnailRequest, +): Promise { + const cacheKey = resourceModelThumbnailCacheKey(request); + const cached = thumbnailCache.get(cacheKey); + if (cached) { + rememberThumbnail(cacheKey, cached); + return cached; + } + const task = renderQueue.then(() => renderOnce(request)); + // 队列本身不能因为某一张失败就断掉:失败向调用方抛,队列继续排下一个。 + renderQueue = task.catch(() => undefined); + const dataUrl = await task; + rememberThumbnail(cacheKey, dataUrl); + return dataUrl; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts index 998c5b9f5..4e305c3b0 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts @@ -74,6 +74,20 @@ export type ProjectResourceTypeLabel = | 'Agent 回执' | '项目版本' | '游戏代码' + | '模型' + | '动画' + | '骨骼动画' + | '场景' + | '预制体' + | '瓦片地图' + | '材质' + | '特效' + | '图集' + | '位图字体' + | '纹理' + | '自动图集' + | '音频片段' + | '二进制' | '未知'; const documentExtension = @@ -82,6 +96,20 @@ const gameCodeExtension = /\.(html?|css|scss|less|m?[jt]sx?|cjs|rs|py|go|java|kt|kts|c|cc|cpp|h|hpp|cs|swift|php|rb|lua|sh|bash|zsh|sql|graphql|gql|vue|svelte)$/iu; const artExtension = /\.(png|jpe?g|webp|gif|svg|avif|bmp|mp4|webm|mov)$/iu; const audioExtension = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/iu; +/* + * 引擎(Cocos 3.8.8)资源扩展名。四组扩展名与原生侧的三份表 + * (`bridge_project_file_class` / `agent_local_project_file_type` / `prompt_context_media_type`) + * 必须同步:少一个扩展名在这里,资源就会「登记得了、画布不显示」,而且没有任何报错。 + */ +/** 三维模型与网格数据。 */ +const engineModelExtension = /\.(glb|gltf|fbx|mesh|skeleton)$/iu; +/** 图像容器(浏览器解不了,原生侧转码成 PNG 后按图片显示)。 */ +const engineImageContainerExtension = /\.(tga|tif|tiff|hdr|exr|psd|znt)$/iu; +/** 材质与特效:登记为 `code`,卡面另行按结构预览。 */ +const engineMaterialExtension = /\.(mtl|material|pmtl|effect|chunk)$/iu; +/** 场景、动画、图集与容器:登记为 `document`,卡面按结构预览或类型卡。 */ +const engineStructuredExtension = + /\.(scene|fire|prefab|anim|animation|animgraph|animgraphvari|animask|tmx|terrain|plist|labelatlas|atlas|fnt|pac|dbbin|bin|skel|texture|cubemap|rt)$/iu; const gameCodeKind = /(?:^|[-_])(game-(?:entry|style|script)|code|source)(?:$|[-_])/iu; const artKind = @@ -132,10 +160,17 @@ export function projectedResourceKind(input: { if ( normalizedMediaType.startsWith('image/') || normalizedMediaType.startsWith('video/') || - artExtension.test(normalizedPath) + artExtension.test(normalizedPath) || + engineModelExtension.test(normalizedPath) || + engineImageContainerExtension.test(normalizedPath) ) { return 'art'; } + // 引擎材质 / 特效排在文档之前:它们的 mediaType 多为 `application/json`, + // 一旦先落到文档分支,画布分类就和登记时的 kind 对不上。 + if (engineMaterialExtension.test(normalizedPath)) { + return 'code'; + } if ( normalizedMediaType === 'text/html' || normalizedMediaType === 'text/css' || @@ -149,7 +184,8 @@ export function projectedResourceKind(input: { normalizedMediaType.includes('json') || normalizedMediaType.includes('yaml') || normalizedMediaType.startsWith('text/') || - documentExtension.test(normalizedPath) + documentExtension.test(normalizedPath) || + engineStructuredExtension.test(normalizedPath) ) { return 'document'; } @@ -203,6 +239,55 @@ export function projectResourceTypeLabel( if (subtype === 'agent-result') { return 'Agent 回执'; } + /* + * 引擎资源的类型角标先按扩展名判定。 + * + * 它们在 manifest 里复用的是既有 canonical kind(模型/场景 → `scene`,动画 → + * `character-animation`,材质/特效 → `code`,容器 → `document`),只用 kind 反推 + * 会把这些资源一律说成「图片」「文档」或「游戏代码」,用户看不出这是什么。 + */ + if (/\.(glb|gltf|fbx|mesh)$/iu.test(path)) { + return '模型'; + } + if (/\.(anim|animation|animgraph|animgraphvari|animask)$/iu.test(path)) { + return '动画'; + } + if (/\.(skeleton|skel|dbbin)$/iu.test(path)) { + return '骨骼动画'; + } + if (/\.(scene|fire|terrain)$/iu.test(path)) { + return '场景'; + } + if (/\.prefab$/iu.test(path)) { + return '预制体'; + } + if (/\.tmx$/iu.test(path)) { + return '瓦片地图'; + } + if (/\.(mtl|material|pmtl)$/iu.test(path)) { + return '材质'; + } + if (/\.(effect|chunk)$/iu.test(path)) { + return '特效'; + } + if (/\.(plist|atlas|labelatlas)$/iu.test(path)) { + return '图集'; + } + if (/\.fnt$/iu.test(path)) { + return '位图字体'; + } + if (/\.(texture|cubemap|rt)$/iu.test(path)) { + return '纹理'; + } + if (/\.pac$/iu.test(path)) { + return '自动图集'; + } + if (/\.pcm$/iu.test(path)) { + return '音频片段'; + } + if (/\.bin$/iu.test(path)) { + return '二进制'; + } if (kind === 'code') { return '游戏代码'; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 729658ceb..508a16e11 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -95,6 +95,12 @@ function resourceReadKindLabel(kind: ProjectResourceCardPreviewKind) { if (kind === 'code') { return '游戏代码'; } + if (kind === 'structured') { + return '引擎资源'; + } + if (kind === 'model') { + return '模型'; + } return '资源'; } @@ -532,6 +538,22 @@ export function useProjectResourceCardPreviews(input: { }, ); } + /** + * 引擎序列化资源走独立读取:准入白名单与服务 UI 编辑器的那份**分开**, + * 且允许「不是 UTF-8」的文件正常返回 `content: null`(卡面降级成类型卡), + * 而不是把它报成一次预览失败。 + */ + if (kind === 'structured') { + return invoke( + 'read_local_project_structured_preview', + { + projectPath: input.projectPath, + relativePath: job.resource.path, + scopeId: job.scopeId, + requestId: createProjectResourcePreviewRequestId(), + }, + ); + } return invoke( 'read_local_project_media_preview', { @@ -643,6 +665,9 @@ export function useProjectResourceCardPreviews(input: { if ( kind === 'version' || kind === 'placeholder' || + // 类型卡不读字节:客户端解不了的容器(压缩纹理、Spine 二进制、PSD/EXR、裸 PCM) + // 读进来也画不出东西,占读取槽只会挤掉真正能出图的卡片。 + kind === 'binary' || (kind === 'audio' && reason !== 'play') ) { return; @@ -1090,7 +1115,12 @@ export function useProjectResourceCardPreviews(input: { continue; } const kind = projectResourceCardPreviewKind(resource); - if (kind === 'version' || kind === 'placeholder' || kind === 'audio') { + if ( + kind === 'version' || + kind === 'placeholder' || + kind === 'audio' || + kind === 'binary' + ) { continue; } const element = elementsByIdentity.get(identity); @@ -1122,7 +1152,8 @@ export function useProjectResourceCardPreviews(input: { identity && kind !== 'version' && kind !== 'placeholder' && - kind !== 'audio' + kind !== 'audio' && + kind !== 'binary' ) { requestPreview(resource, identity, 'visible'); fallbackRequested += 1; diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 636ec3d74..6c6d4d9b9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3460,8 +3460,27 @@ export function registerProjectWorkbenchFoundationTests() { resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), 'utf8', ); - // 卡片本体不再自带描边;只有被当前版本绑定的素材才有边框。 - expect(styles).toMatch(/\.game-resource-card\s*\{[^}]*border:\s*0;/s); + /* + * 卡片本体只保留**透明**边框,可见描边仍然只属于「当前版本绑定」等状态。 + * + * 透明而不是 `border: 0`:卡片是 `box-sizing: border-box`,卡面与角标又都是以 padding + * box 为包含块的绝对定位元素 —— 底态 0 宽、状态态 1px 宽时,一次悬停就会把内容盒四边 + * 各吃掉 1px,卡片里的东西跟着位移。常驻 1px 透明边框让所有状态的 padding box 一致, + * 视觉上仍"本体无描边"。 + */ + expect(styles).toMatch( + /\.game-resource-card\s*\{[^}]*border:\s*1px solid transparent;/s, + ); + // 状态态只允许点亮颜色,不允许改动宽度:宽度一变就又回到"内容跟着动"。 + expect(styles).toMatch( + /\.game-resource-card:hover,[^{]*\{[^}]*border:\s*1px solid/s, + ); + expect(styles).not.toMatch( + /\.game-resource-card[^{,]*\{[^}]*border-width:/s, + ); + expect(styles).not.toMatch( + /\.game-resource-card[^{,]*\{[^}]*border:\s*[2-9]px/s, + ); expect(styles).toMatch( /\.game-resource-card\.is-current-version\s*\{[^}]*border:\s*1px solid/s, ); diff --git a/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx new file mode 100644 index 000000000..81f031887 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx @@ -0,0 +1,461 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +// 浮层外壳(portal + 焦点陷阱)不在本用例关注面内:与文档预览浮层的用例同口径只渲染子节点。 +vi.mock('../src/components/modal/ThemedModal', () => ({ + ThemedModal: ({ + children, + ariaLabel, + }: { + children: ReactNode; + ariaLabel: string; + }) => ( +
+ {children} +
+ ), +})); + +import { + projectResourceCardPreviewKind, + projectResourceCardPreviewReadsContent, + projectResourceCocosStructureSummary, + projectResourceMediaPreviewCategory, + projectResourceStructuredPreviewText, +} from '../src/view/project-development/resourceCardPreviewModel'; +import { ResourceModelPreview } from '../src/view/project-development/ResourceModelPreview'; +import { ResourceModelPreviewDialog } from '../src/view/project-development/ResourceModelPreviewDialog'; +import { + resourceModelThumbnailCacheKey, + resourceModelThumbnailIdentity, +} from '../src/view/project-development/resourceModelThumbnail'; +import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; +import { + projectedResourceKind, + projectResourceTypeLabel, +} from '../src/view/project-development/resourceProjectionModel'; + +type EngineCase = { + path: string; + mediaType: string; + kind: string; + projectedKind: 'art' | 'audio' | 'code' | 'document'; + previewKind: + | 'model' + | 'structured' + | 'binary' + | 'media-image' + | 'audio' + | 'document' + | 'code'; + typeLabel: string; + readsContent: boolean; +}; + +/** + * Cocos Creator 资源在资源画布上的**准入 + 卡面分支 + 类型角标**三合一决策表。 + * + * 这张表是前后端三份扩展名表(原生发现 / 原生登记 / 前端投影)的交叉契约:表里任何一行 + * 对不上,都会表现为「登记得了但画布不显示」或「显示了但预览是坏图」。 + */ +const ENGINE_CASES: EngineCase[] = [ + { + path: 'assets/model/hero.glb', + mediaType: 'model/gltf-binary', + kind: 'scene', + projectedKind: 'art', + previewKind: 'model', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.gltf', + mediaType: 'model/gltf+json', + kind: 'scene', + projectedKind: 'art', + previewKind: 'model', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.fbx', + mediaType: 'application/octet-stream', + kind: 'scene', + projectedKind: 'art', + previewKind: 'model', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.mesh', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'art', + previewKind: 'structured', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.skeleton', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'art', + previewKind: 'structured', + typeLabel: '骨骼动画', + readsContent: true, + }, + { + path: 'assets/anim/walk.anim', + mediaType: 'application/json', + kind: 'character-animation', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '动画', + readsContent: true, + }, + { + path: 'assets/anim/graph.animgraph', + mediaType: 'application/json', + kind: 'character-animation', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '动画', + readsContent: true, + }, + { + path: 'assets/scene/main.scene', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '场景', + readsContent: true, + }, + { + path: 'assets/scene/enemy.prefab', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '预制体', + readsContent: true, + }, + { + path: 'assets/map/level.tmx', + mediaType: 'application/xml', + kind: 'scene', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '瓦片地图', + readsContent: true, + }, + { + path: 'assets/mtl/hero.mtl', + mediaType: 'application/json', + kind: 'code', + projectedKind: 'code', + previewKind: 'structured', + typeLabel: '材质', + readsContent: true, + }, + { + path: 'assets/shader/glow.effect', + mediaType: 'text/plain', + kind: 'code', + projectedKind: 'code', + previewKind: 'structured', + typeLabel: '特效', + readsContent: true, + }, + { + path: 'assets/atlas/hero.plist', + mediaType: 'application/xml', + kind: 'document', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '图集', + readsContent: true, + }, + { + path: 'assets/font/bitmap.fnt', + mediaType: 'text/plain', + kind: 'document', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '位图字体', + readsContent: true, + }, + { + path: 'assets/tex/grass.tga', + mediaType: 'image/x-tga', + kind: 'image', + projectedKind: 'art', + previewKind: 'media-image', + typeLabel: '图片', + readsContent: true, + }, + { + path: 'assets/tex/height.hdr', + mediaType: 'image/vnd.radiance', + kind: 'image', + projectedKind: 'art', + previewKind: 'media-image', + typeLabel: '图片', + readsContent: true, + }, + { + path: 'assets/tex/hero.texture', + mediaType: 'application/octet-stream', + kind: 'document', + projectedKind: 'document', + previewKind: 'binary', + typeLabel: '纹理', + readsContent: false, + }, + { + path: 'assets/spine/hero.skel', + mediaType: 'application/octet-stream', + kind: 'document', + projectedKind: 'document', + previewKind: 'binary', + typeLabel: '骨骼动画', + readsContent: false, + }, + { + path: 'assets/audio/voice.pcm', + mediaType: 'audio/pcm', + kind: 'audio', + projectedKind: 'audio', + previewKind: 'binary', + typeLabel: '音频片段', + readsContent: false, + }, +]; + +describe('Cocos 资源在资源画布上的准入与预览契约', () => { + it('每个引擎资源都能进画布、落到预期卡面分支与类型角标', () => { + for (const entry of ENGINE_CASES) { + const resource = { + id: entry.path, + path: entry.path, + mediaType: entry.mediaType, + subtype: entry.kind, + }; + expect( + projectedResourceKind({ + path: entry.path, + mediaType: entry.mediaType, + kind: entry.kind, + }), + `${entry.path} 必须能进画布`, + ).toBe(entry.projectedKind); + expect(projectResourceCardPreviewKind(resource), entry.path).toBe( + entry.previewKind, + ); + expect(projectResourceTypeLabel(resource), entry.path).toBe( + entry.typeLabel, + ); + expect( + projectResourceCardPreviewReadsContent(resource), + `${entry.path} 是否能读字节`, + ).toBe(entry.readsContent); + } + }); + + it('模型走 model 读取分支、图片容器走 art,二进制容器不读字节', () => { + expect( + projectResourceMediaPreviewCategory({ + id: 'glb', + path: 'assets/model/hero.glb', + mediaType: 'model/gltf-binary', + subtype: 'scene', + }), + ).toBe('model'); + expect( + projectResourceMediaPreviewCategory({ + id: 'tga', + path: 'assets/tex/grass.tga', + mediaType: 'image/x-tga', + subtype: 'image', + }), + ).toBe('art'); + }); +}); + +describe('Cocos 序列化资源的结构摘要', () => { + it('从序列化数组里抽出根类型、节点数、时长与资源引用', () => { + const summary = projectResourceCocosStructureSummary( + JSON.stringify([ + { __type__: 'cc.AnimationClip', _name: 'walk', _duration: 1.25 }, + { __type__: 'cc.Node', _name: 'root' }, + { __type__: 'cc.Node', _name: 'child' }, + { __uuid__: 'abc' }, + ]), + ); + expect(summary).toContain('cc.AnimationClip'); + expect(summary).toContain('2 个节点'); + expect(summary).toContain('1.25 秒'); + expect(summary).toContain('walk'); + expect(summary).toContain('1 处资源引用'); + }); + + it('不是序列化数组时不做结构摘要,回退到前几行文本', () => { + const effect = 'CCEffect %{\n techniques: []\n}'; + expect(projectResourceCocosStructureSummary(effect)).toBeNull(); + expect(projectResourceStructuredPreviewText(effect)).toContain('CCEffect'); + }); + + it('二进制变体(原生读不到正文)返回空串,卡面画类型卡', () => { + expect(projectResourceStructuredPreviewText(undefined)).toBe(''); + }); +}); + +describe('模型卡渲染失败时的降级', () => { + it('缩略图缓存键按稳定身份计算,不随 blob URL 变化', () => { + const identity = resourceModelThumbnailIdentity({ + resourceKey: 'asset:hero', + path: 'assets/model/hero.glb', + byteLen: 1024, + }); + const first = resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity, + width: 320, + height: 240, + }); + const second = resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity, + width: 320, + height: 240, + }); + expect(first).toBe(second); + // 同一份资源换了内容(字节数变化)或换了尺寸,都必须重新渲染。 + expect( + resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity: resourceModelThumbnailIdentity({ + resourceKey: 'asset:hero', + path: 'assets/model/hero.glb', + byteLen: 2048, + }), + width: 320, + height: 240, + }), + ).not.toBe(first); + expect( + resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity, + width: 480, + height: 360, + }), + ).not.toBe(first); + }); + + it('渲染不出缩略图时显示类型卡,不冒泡成预览失败', async () => { + // jsdom 没有 WebGL:这正是"渲染不可用"的真实路径,用来钉住降级行为。 + render( + , + ); + expect(screen.getByText('模型 · GLB')).toBeDefined(); + await waitFor(() => { + expect( + document.querySelector('[data-model-preview-status="failed"]'), + ).not.toBeNull(); + }); + }); +}); + +describe('模型放大预览浮层', () => { + const modelResource: ProjectResource = { + id: 'asset:model', + path: 'assets/model/cube.glb', + label: 'cube.glb', + mediaType: 'model/gltf-binary', + category: 'scene', + subtype: 'scene', + manifestAssetId: 'asset:model', + sourceLabel: '', + taskTitle: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + }; + + it('打开即按详情理由请求字节,并给出与建模软件一致的视角操作提示', () => { + const onRequestPreview = vi.fn(); + render( + undefined} + />, + ); + expect(onRequestPreview).toHaveBeenCalledWith( + modelResource, + 'asset:model|assets/model/cube.glb|1548', + 'detail', + ); + expect(screen.getByRole('status').textContent).toContain('正在加载模型'); + expect(document.body.textContent).toContain('左键拖拽旋转'); + expect(document.body.textContent).toContain('滚轮缩放'); + expect(document.body.textContent).toContain('复位视角'); + }); + + it('渲染器不可用时明确说明,不假装已经渲染出模型', async () => { + // jsdom 没有 WebGL:这正是「无法渲染」的真实路径。 + render( + undefined} + onClose={() => undefined} + />, + ); + await waitFor(() => { + expect( + document.querySelector('[data-model-viewer-status="failed"]'), + ).not.toBeNull(); + }); + expect(document.body.textContent).toContain('无法渲染三维预览'); + }); + + it('预览失败时给出错误与重试,不留下空白浮层', () => { + render( + undefined} + onClose={() => undefined} + />, + ); + expect(screen.getByRole('alert').textContent).toContain('模型预览只支持'); + expect(screen.getByText('重试')).toBeDefined(); + }); +}); diff --git a/docs/project-memory/plans/【里程碑】资源画布支持引擎资源预览-2026-09-17.md b/docs/project-memory/plans/【里程碑】资源画布支持引擎资源预览-2026-09-17.md new file mode 100644 index 000000000..2eba81369 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】资源画布支持引擎资源预览-2026-09-17.md @@ -0,0 +1,57 @@ +# 【里程碑】资源画布支持引擎资源预览 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | implemented; 真机 Cocos 工程验收待补 | +| Date | 2026-09-17 | +| Parent Spec | `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` | + +## 目标 + +引擎(Cocos Creator 等)工程里已经存在的资源,可以被登记进 manifest,并作为资源画布的卡片**只读预览**:模型出缩略图、序列化资源出结构摘要、客户端解不了的容器出类型卡。 + +## 范围 + +- 登记与发现:模型、动画、场景/预制体/瓦片地图/地形、材质/特效、图集与字体配置、图像容器、引擎二进制容器。 +- 生成目录过滤:Cocos 工程的 `library/`、`temp/`、`profiles/`、`local/` 不进发现结果(仅当当前目录确实是引擎工程时生效)。 +- 画布准入与卡面:新增「引擎资源」三个卡面分支(模型 / 结构摘要 / 类型卡),并按扩展名给出引擎语义的类型角标。 +- 模型放大预览:选中模型卡后用工具条的「3D 预览」打开浮层,给出与三维建模软件一致的视角操作(左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角)。 +- 只读预览通道:模型字节读取、引擎序列化文本读取(非 UTF-8 时降级)、图像容器原生转码。 + +## 不在范围内 + +- 引擎资源的编辑、派生、生成与回写(画布上仍是只读预览;「快速编辑」等现有工具链不承接这些类型)。 +- 引擎私有格式的解码:压缩纹理(`.texture` / `.cubemap` / `.rt`)、Spine 二进制(`.skel`)、DragonBones 二进制(`.dbbin`)、PSD、EXR、裸 PCM 只出类型卡。 +- 新增 manifest 契约字段(`cocosUuid` 等)、新增 canonical kind、新增画布分类轴:本轮复用既有 kind,避免旧客户端读不出 manifest(`deny_unknown_fields`)。 +- 多文件 glTF(`baseURI` 指向外部 `.bin` / 贴图)的渲染:卡面只渲染自包含的 `.glb` / 单文件 `.gltf` / `.fbx`,其余降级成类型卡(本轮选择放宽字节上限,不做资源路径解析)。 +- 项目快照 / 版本指纹 / 检查点对生成目录的口径:本轮只过滤**发现结果**,不改快照语义。 + +## 依赖与前置条件 + +- 现有四道闸门:发现(`bridge_project_file_class`)、登记(`agent_local_project_file_type`)、画布准入(`projectedResourceKind`)、卡面读取(`resourceCardPreviewModel` + 原生预览命令)。 +- 既有预览管线(可见性门禁、3 槽并发、LRU 预算、取消与失败语义)不改口径。 +- 前端新增 `three` 运行时依赖(缩略图渲染器)与 `@types/three` 类型依赖。 + +## 验收标准 + +- [x] 表内引擎资源都能通过发现层拿到非空 `mediaType`,并按 `model` / `binary` / `image` / `document` / `audio` 等类别被筛出。 +- [x] 表内引擎资源都能登记进 manifest,`kind` 只落在既有 canonical 词表内。 +- [x] 表内引擎资源都能进入资源画布,并落到预期的卡面分支与类型角标。 +- [x] 模型卡在渲染不可用时降级成类型卡,不冒泡成预览失败。 +- [x] 引擎序列化资源的非 UTF-8 变体降级成类型卡,不报错。 +- [x] 图像容器(`.tga` / `.tif` / `.tiff` / `.hdr`)由原生侧转码成 PNG 后按图片卡显示。 +- [x] 引擎工程的生成目录不出现在发现结果里,同名目录在非引擎工程里照常列出。 +- [x] 模型预览上限放宽到与通用媒体预览一致(32 MiB),超出后仍是类型卡而不是半渲染。 +- [x] 模型卡可以放大到独立浮层里交互查看:拖动与滚轮都真实改变画面,「复位视角」回到打开时的取景。 +- [x] 真机 Cocos 工程(含模型与动画资源)在客户端内滚动浏览的视觉验收。 + +## 证据要求 + +- 自动化:`cargo check`;`cargo fmt --check`;`cargo test --bin genarrative-ai-game-creator-shell cocos`(9 通过,含发现分类 / 登记 / 提示词 / 插件门禁);`cargo test … resource_inspect::tests`(7 通过:结构化预览与二进制降级、TGA→PNG 转码、模型媒体类型签名 + 既有文本 / SVG / 尺寸用例);`cargo test … agent_asset_import_tests`(9 通过);`cargo test … local_project_file_listing_skips_engine_generated_directories_only_for_engine_projects`;`npx vitest run apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx`(7 通过);`npx vitest run apps/ai-game-creator-shell/tests/resource apps/ai-game-creator-shell/tests/project`(52 文件 / 543 用例);`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(450 通过 / 20 跳过);`npm run typecheck`(app);`npm run check:encoding`;`git diff --check`;`npm run check:doc-index`。 +- 运行时(2026-09-17 完成):在真实客户端(`npm run agc` 起的 Tauri 客户端 + WebView2 CDP 驱动)里打开一个**本地临时 Cocos 夹具工程**(含模型 / 动画 / 序列化资源 / TGA / 引擎容器,不入库),经 `import_local_cocos_project` 与受控登记导入 10 个引擎资源后逐栏核对:模型卡出真实三维缩略图(`data-model-preview-status=ready`,卡面是渲染出来的 PNG data URL);`.anim` / `.scene` / `.prefab` / `.plist` / `.effect` 出结构摘要或文本预览;`.tga` 出真实图片(原生转码生效);`.texture` / `.bin` / `.pcm` 出「纹理 · TEXTURE」「二进制 · BIN」「音频片段 · PCM」类型卡。验收截图(本地留存,不入库):场景与环境栏(模型 + 摘要)、文档栏(类型卡)、待归类栏(TGA 转码)、音频栏、模型放大预览。 +- 运行时(目录过滤):同一现场调 `list_local_project_files`,根级 `library/` / `temp/` / `profiles/` **0 条**,而 `assets/library/inside.bin` 正常列出。 +- 运行时(模型交互):同一现场选中 `cube.glb` → 工具条出现「3D 预览」→ 浮层内 `data-model-viewer-status=ready`、canvas 已挂载;真实鼠标拖拽与滚轮各产生一次不同的渲染结果(三张截图 MD5 互不相同),点「复位视角」后的截图与打开时**逐字节一致**(MD5 `A6531331206456C666909F80384B53AD`)。证据:`agc-model-viewer-initial.png` / `agc-viewer-rotated.png` / `agc-viewer-zoomed-out.png` / `agc-viewer-reset.png`。 +- 运行时(卡面几何):同一现场量 `cube.glb` 卡的卡片盒 / 卡面 / 类型角标 / 缩略图四个矩形的 `x/y/w/h`,在**指针移开、悬停、选中**三种状态下**完全一致**(此前悬停会把卡面四边各吃 1px);缩略图渲染尺寸为 `356x252`(布局盒 178×126 × 2 超采样),绘制比 1.4127 与卡面盒 267×189 的比例一致,缩放后不发虚、不裁切。证据:`agc-fix-hover.png` / `agc-fix-selected.png`。 +- 边界:`.meta` 仍然只可发现、不可登记;引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不进发现结果,同名目录在非引擎工程里照常列出;`.pcm` / `.texture` 等容器不发起读取。 +- 未验证:真机客户端内的视觉验收(本机没有可用的 Cocos 工程实例)。Rust 侧其余仍用 `tempfile::tempdir()` 的既有用例在本机仍被 `Windows 安全对象不属于当前用户` 阻断(本次把预览与导入这两个模块的用例改用工程自带的 `crate::tests::canonical_test_tempdir`,它们已能真实跑通)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ddb8c3926..f1edf3f71 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -22,6 +22,21 @@ - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`(上限与文案的唯一口径)、`agent/direct_tool_bridge.rs`(按 kind 判定与未登记源资源提示)、`agent/direct_tools_mcp.rs`(schema 与校验)、`resources/agc-skills/agc-client-projection/**` 与清单指纹(version `2026-08-26.18`)。**未改** `/api/external/v1` 契约与 OpenAPI、SpacetimeDB schema、前端 TS 侧 `resourceEditPromptMaxLength` 数字、客户端 UI 行为。 - 验证方式:新增 `tool_prompt_limits_agree_with_the_client_authority`(四个 kind 的 schema 上限、MCP 校验与客户端权威口径同数字,超限文案带真实上限)、`bridge_resource_prompt_limits_follow_the_client_authority`(工具桥侧同类门禁,含图片编辑的 32000 边界)、`edit_image_tool_reaches_the_platform_image_edit_route` 与 `background_music_tool_reaches_the_platform_audio_route`(MCP 工具层 → 真实工具桥 → 假平台,断言 `/api/editor/images/edits` 与 `/api/editor/audios/background-music/generations` 的路径、Bearer、Idempotency-Key、正文与派生资源落盘,图片编辑正文不得回填 assetKind)、`background_music_prompt_over_the_limit_is_rejected_before_any_bridge_call`(超限在桥请求之前失败)、`unregistered_source_reports_the_registration_follow_up_tools`;`agent::direct_tools_mcp` 22 passed、`agent::skill_pack` 4 passed、`agent::direct_tool_bridge` 17 passed(7 条本机既有失败见下)、`npm run agc:skill-pack:check` 与 `skill-pack:test` 通过。本机 `tempfile::tempdir()` 归属校验失败导致的既有用例(`project::resource_editor` 45 条、`agent::direct_tool_bridge` 7 条)在本轮改动前后**同为失败**(stash 基线复跑确认),与本次无关。 - 关联文档:[AI游戏创作智能体App实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)、[踩坑记录](pitfalls.md)。 +## 2026-09-17 资源画布支持引擎资源只读预览 + +- 背景:Cocos Creator 工程里已有的引擎资源(模型、动画、预制体、材质、图集、压缩纹理…)此前在发现层就止步:`.glb` / `.prefab` / `.anim` / `.texture` 等扩展名既不可登记,也不进资源画布,工程导入后画布上只看得到位图、音频与脚本。 +- 决策(范围):本轮只做**只读预览**。引擎资源可以被发现、登记进 manifest、进入资源画布并按类型出预览;不承接编辑、派生、生成与回写,也不解码引擎私有容器(`.texture` / `.cubemap` / `.rt` / `.skel` / `.dbbin` / `.psd` / `.exr` / `.pcm` 只出类型卡)。 +- 决策(契约):**不新增 manifest 契约字段、不新增 canonical kind、不新增画布分类轴**。引擎资源复用既有 kind(模型/场景/预制体/地形 → `scene`,动画 → `character-animation`,材质/特效 → `code`,图集与容器 → `document`,图像容器 → `image`,裸 PCM → `audio`),避免 `deny_unknown_fields` 让旧客户端读不出整份 manifest;引擎语义由**类型角标**(模型 / 动画 / 材质 / 图集 / 纹理…)表达,不复用「图片 / 文档」。 +- 决策(卡面与读取):新增三个卡面分支 —— `model`(`.glb` / `.gltf` / `.fbx`,由单例 WebGL 渲染器出缩略图,整页只保留一个 WebGL 上下文)、`structured`(Cocos 序列化资源的结构摘要,非 UTF-8 变体降级成类型卡而不是报错)、`binary`(不发起任何读取,不占预览读取槽)。图像容器(`.tga` / `.tif` / `.tiff` / `.hdr`)先在原生侧转码成 PNG,再走既有图片预览链路。 +- 边界:`.meta` 等引擎导入侧车文件仍然只可发现、不可登记;发现层新增 `model` / `binary` 两个**发现类别**(不是 manifest kind)。多文件 glTF(外部 `.bin` / 贴图)与超限模型降级成类型卡;预览管线既有语义(可见性门禁、3 槽并发、LRU 预算、取消与重试口径)不变。 +- 决策(发现过滤):引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不再进发现结果,判定收窄为「工程根直接子目录 + 当前目录确实是 Cocos Creator 工程(`package.json.creator.version` + `assets/`)」。**不放进全局跳过表**:这些名字在别的工程里可能是真实源码目录。过滤落在唯一一份目录遍历(`list_local_project_files_at`)上,因此 Agent 发现、前端资源树与提示词里的未登记清单同步生效;项目快照 / 版本指纹 / 检查点的语义本轮不动。 +- 决策(模型上限与缓存键):模型预览字节上限从 16 MiB 放宽到 32 MiB,与通用媒体预览取同一上限(base64 载荷约 43 MiB);超过上限仍是类型卡,不做半渲染。缩略图缓存键改用**稳定身份**(资源身份 + 路径 + 字节数)而不是 blob URL:预览缓存淘汰后重读同一模型不会重新解析 + 重新渲染。要再往上放宽,必须先把预览载荷换成 Tauri 原始字节通道。 +- 决策(模型放大预览):模型卡可以在工具条打开「3D 预览」独立浮层,浮层内是**交互式视角**(OrbitControls:左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角),与三维建模软件同一套操作习惯。加载 / 取景 / 释放三条口径抽到共用模块 `resourceModelScene`,缩略图与浮层不许各写一套;画布上的卡片仍然是静态缩略图并继续共用**唯一**一个 WebGL 上下文,只有打开浮层时才新建交互式上下文,关闭即 dispose。浮层仍是只读预览:不写 manifest、不参与编辑与派生。 +- 验证:`cargo check`、`cargo fmt --check` 通过;`cargo test … cocos` 9 条通过(发现分类 / 登记 / 提示词投影 / 插件门禁);`cargo test … resource_inspect::tests` 7 条通过(含结构化预览与二进制降级、TGA→PNG 转码、模型签名判定);`cargo test … agent_asset_import_tests` 9 条通过;生成目录过滤用例通过(引擎工程过滤、非引擎工程不过滤);`tests/resourceCocosPreviewContract.test.tsx` 7 条通过(含模型卡渲染不可用时的降级、稳定缓存键);`npx vitest run apps/ai-game-creator-shell/tests/resource apps/ai-game-creator-shell/tests/project` 52 文件 / 543 用例通过;`appSurface.test.ts` 450 通过 / 20 跳过;app `tsc --noEmit`、`npm run check:encoding`、`git diff --check`、`npm run check:doc-index` 通过。 +- 真机验收(2026-09-17 补):在真实客户端内打开一个含模型 / 动画 / 序列化资源 / TGA / 引擎容器的 Cocos 夹具工程,模型卡出三维缩略图、序列化资源出结构摘要、TGA 出转码后的真实图片、引擎容器出类型卡;同现场 `list_local_project_files` 对根级 `library/` / `temp/` / `profiles/` 返回 0 条、`assets/library/` 正常列出。证据见里程碑文档「证据要求」。 +- 未验证:本机其余仍用 `tempfile::tempdir()` 的既有 Rust 用例继续被 `Windows 安全对象不属于当前用户` 阻断(与本决策无关;根因与手工夹具相同,已记入 `pitfalls.md`)。 +- 关联文档:`docs/project-memory/plans/【里程碑】资源画布支持引擎资源预览-2026-09-17.md`。 + ## 2026-09-16 抠图模式与背景色契约 - External v1 抠图和 AGC `agc_remove_background` 支持 `complex`(语义分割识别前景)与 `flat`(纯色背景抠图);明确纯色背景优先 flat,模式缺省仍为 complex,主站前端保持现有行为。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 52b39d70a..be2d7f4b7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -8,6 +8,16 @@ - **易错点**:① 把弹层改成 `left: 0` 或往右挪也能让它可见,但那是改变展开方向,弹层会跑到触发钮右边(用户明确否决);② 只放开最外层聊天列不够——surface 与 conversation 各自都会裁,三层必须同时放开;③ 只按宽度比大小会误判:280px 面板里控制排本身也超出(发送钮右侧溢出 22px,被窗口右缘吃掉),那不是本条的原因,别顺手去改控制排布局。 - **验证**:`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的 `keeps the landscape workbench edge-to-edge with internal chat scrolling` 钉住三条 override 声明在场(删掉任一条即红)。真机几何用 playwright-cli 打开一份只含真实 `styles.css` 与真实 composer DOM 的最小复现页实测(视口 1000×700、面板 280px):弹层 rect 修复前后都是 `[-42, 108]`(位置未动),`elementFromPoint` 的命中区间从修复前的 `[2, 108]` 变成整块;档位文字在截图中完整可见。 - **关联**:`apps/ai-game-creator-shell/src/styles.css`(`面板纵向布局(2026-07 Codex 风格改造)` 区块之后)、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`。 +## 2026-09-17 资源卡「内容跟着边框动」与「模型缩略图被拉伸」是两条不同的几何陷阱 + +- **内容跟着状态边框位移**:卡片底态是 `border: 0`,悬停 / 选中才加 1px 边框;卡片是 `box-sizing: border-box`,而卡面(`.game-resource-card-visual`)与角标都是 `position: absolute; inset: 0`(包含块 = **padding box**)⇒ 状态一切换,内容盒四边各被吃掉 1px,卡面与角标整体位移并缩小 2px。修法:底态写成 `border: 1px solid transparent;`,状态只点亮 `border-color`;契约用例改成断言「资源卡规则里不得出现非 1px 的 `border` / `border-width`」。 +- **模型缩略图看起来被拉伸 / 被裁**:三个原因叠在一起 —— ① 缩略图渲染器的 `PerspectiveCamera` 是单例复用件,`aspect` 默认 `1` 且从没更新,方形投影被塞进宽扁缓冲;② 卡面里 `width/height: 100%` 的图片挂在 `place-items: center` 的网格里,网格项高度会退化成"按内容定高"(百分比高度解析成 `auto`),图片按自然比例长过卡片、被 `overflow: hidden` 裁掉,看起来就像被拉伸;③ 栏目画布用 `transform: scale(var(--resource-section-zoom))` 放大(真机 1.5 倍),而 `ResizeObserver` / `offsetWidth` 只看**布局盒**,缩放变化既不触发 observer,按布局尺寸 1:1 渲染的图也会被放大到发虚。修法:渲染前设 `camera.aspect`;图片改 `position: absolute; inset: 0` + `object-fit: contain`;渲染尺寸取 `offsetWidth/offsetHeight × 2` 超采样(上限 1024),并把实际渲染尺寸暴露到 `data-model-render-size` 方便排障。 + +## 2026-09-17 从提权会话创建的目录会被 AGC 的 Windows owner 校验直接拒绝 + +- **现象**:在 Codex 会话里手工创建的工程目录(例如 `C:\Users\\Documents\Codex\...\cocos-preview-fixture`),用客户端打开时报 `Windows 安全对象不属于当前用户:`;Rust 侧同样用 `tempfile::tempdir()` 建夹具的用例也成片失败在同一句上。 +- **原因**:这个 shell 以管理员身份运行,`New-Item` / `tempfile` 新建目录的 owner 是 `BUILTIN\Administrators`,而 AGC 的校验要求 owner 等于当前用户 SID(`KDLETTERS\`)。`Get-Acl | Select Owner` 与 `whoami` 一比就能定性;同一台机器上由客户端自己创建的目录 owner 正确,所以「客户端自己建的项目能用、手建的不能用」。 +- **处理**:手工夹具先 `icacls /setowner "\" /T`;Rust 用例改用工程自带的 `crate::tests::canonical_test_tempdir(prefix)`(它会 canonicalize 并重置目录 owner),不要直接用 `tempfile::tempdir()`。判「用例失败与本改动无关」时,先确认失败信息是不是这一条。 ## DirectProject 历史不能按工具条目切页再按消息推进游标 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 550e75f0c..fe8462063 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -152,7 +152,7 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm - 素材读取区分三类来源:`asset.list` / `agc_list_registered_assets` 是当前项目本地 manifest,`agc_list_project_files` / `file.list` 只发现项目目录中实际存在但可能未登记的文件,`asset.library.list` 是当前登录账号素材库,项目画布资源读取是当前网页项目/画布的完整图片清单;账户素材库不能替代项目画布清单。 - Agent 只接收稳定素材 ID、类型、尺寸和项目相对路径等安全投影。客户端负责重新校验账号/项目归属、换签下载、媒体校验,以及 manifest/画布原子登记;不得向 Agent 暴露绝对路径、签名 URL、objectKey、token 或 Cookie。 -- `canvas.asset_import` 支持账户/画布资源 ID 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的已识别图片、字体、音频、视频、文档和代码文件与其它文件;Agent 只能提交前者。导入拒绝路径穿越、`.agent`、符号链接/reparse point 及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。 +- `canvas.asset_import` 支持账户/画布资源 ID 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的已识别图片、字体、音频、视频、文档、代码与**引擎资源**(Cocos Creator 的模型、动画、场景/预制体、材质/特效、图集与压缩纹理容器)与其它文件;Agent 只能提交前者。引擎资源在资源画布上是**只读预览**:模型出缩略图、序列化资源出结构摘要、客户端解不了的容器出类型卡,不承接编辑与派生;`.meta`、`library/`、`temp/` 等引擎生成物仍然只可发现、不可登记。导入拒绝路径穿越、`.agent`、符号链接/reparse point 及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。 - Runtime `asset.list` 与 `file.list` 的详情使用文件上下文上限,而不是普通工具短摘要上限,确保有界候选/目录清单不会因前部内容较长而整体丢失;`asset.list` 超出 48 项或 `file.list` 超出 40 项时仍显式返回剩余数量,Agent 再按候选父目录(例如 `assets`、`game/assets`)缩小范围查询。 - 结果仅返回成功/跳过/失败数量、安全 ID、相对路径、来源、脱敏失败摘要和实际 `revisionAdvanceCount`;幂等跳过不得虚增 revision,部分失败仍须准确记录已发生的 revision 变化。 - 普通 Prompt 上下文与错误诊断必须使用分离的脱敏边界:Prompt 继续对疑似凭据行整体隐藏;错误诊断保留 HTTP 状态以及 `code / field / message / reason / detail` 等安全字段,仅替换 Token、Cookie、私钥、配置名、URL 和宿主路径等敏感值。`agc_create_or_derive_resource.assetName` 是必填的人类可读资源显示名称,不接受项目路径、URL、objectKey、Token 或其它凭据。 diff --git a/package-lock.json b/package-lock.json index ccb4be9ec..ab6ee8ab6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -122,6 +122,7 @@ "react-window": "^1.8.11", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", + "three": "^0.184.0", "vite": "^6.2.0", "zustand": "^5.0.14" }, @@ -134,6 +135,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", + "@types/three": "^0.184.1", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", "vitest": "^0.34.6" @@ -1497,6 +1499,13 @@ } } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -8234,6 +8243,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -8449,6 +8465,28 @@ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -8461,6 +8499,13 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -12678,6 +12723,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -15697,6 +15749,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/metro": { "version": "0.84.4", "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", @@ -23896,6 +23955,12 @@ "react-icons": "^5.4.0" } }, + "@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true + }, "@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -26475,6 +26540,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", + "@types/three": "^0.184.1", "@vitejs/plugin-react": "^5.0.4", "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", @@ -26489,6 +26555,7 @@ "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.14", + "three": "^0.184.0", "typescript": "~5.8.2", "vite": "^6.2.0", "vitest": "^0.34.6", @@ -28333,6 +28400,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true + }, "@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -28531,6 +28604,26 @@ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true }, + "@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true + }, + "@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "dev": true, + "requires": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, "@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -28541,6 +28634,12 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" }, + "@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true + }, "@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -31379,6 +31478,12 @@ "integrity": "sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA==", "dev": true }, + "fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true + }, "figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -33354,6 +33459,12 @@ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true }, + "meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true + }, "metro": { "version": "0.84.4", "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz",