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 7331497f1..211f0fdc8 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 @@ -5316,7 +5316,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 447a549ab..d5516dd5f 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()); } @@ -3183,7 +3212,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}" @@ -3203,6 +3234,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 83920e1c8..d3a8f48ba 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}"))?; @@ -5156,6 +5417,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>, @@ -5195,7 +5515,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 @@ -5206,6 +5527,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 @@ -5217,6 +5541,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 64f552a15..503eaf238 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 @@ -800,17 +800,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { "tool": "canvas.asset_generate", "reason": "生成可用于首版原型的主角素材", "input": { - "prompt": "透明 PNG 像素月光主角,适合厨房弹幕游戏", + "prompt": "透明 PNG 像素月光主角图集,按 2 行 2 列等分网格排布,适合厨房弹幕游戏", "outputPath": "assets/art-spritesheet.png", "aspectRatio": "1:1", "imageSize": "1K", "assetKind": "art-spritesheet", "assetLabel": "游戏首版核心美术素材", "replaceExisting": false, - // 图集切分没有默认值:必须显式声明,且与平台回显的 sliceMode/gridX/gridY 一致。 "sliceMode": "grid", "gridX": 2, - "gridY": 2 + "gridY": 2, + "sliceCount": null } } ], @@ -851,7 +851,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { start_game_creator_agent_background_task_at( &root, "art-asset-plan", - "为月光厨房生成首版主角素材", + "为月光厨房生成首版主角素材图集,按 2 行 2 列等分网格排布", "art-generate-run", ) .expect("start background task"); @@ -1009,6 +1009,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { let generation_idempotency_key = request_header(generation_request, "idempotency-key").expect("generation idempotency key"); assert!(uuid::Uuid::parse_str(&generation_idempotency_key).is_ok()); + let generation_body: Value = serde_json::from_str( + generation_request + .split_once("\r\n\r\n") + .expect("generation request body") + .1, + ) + .expect("generation request json"); + assert_eq!(generation_body["sliceMode"], "grid"); + assert_eq!(generation_body["gridX"], 2); + assert_eq!(generation_body["gridY"], 2); + assert!(generation_body["sliceCount"].is_null()); assert!(generation_request.contains(r#""source":"ai-game-creator-client""#)); assert_eq!( canvas_requests @@ -3551,6 +3562,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-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index d71500895..f8284977f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -6947,6 +6947,10 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { canvas_asset.parameters["properties"]["input"]["properties"]["imageSize"]["enum"], serde_json::json!(["0.5K", "1K", "2K", null]) ); + assert_eq!( + canvas_asset.parameters["properties"]["input"]["properties"]["sliceMode"]["enum"], + serde_json::json!(["connected-components", "grid", null]) + ); assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"], serde_json::json!([ diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index a9d4c478e..f7b69de22 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -45,7 +45,8 @@ export function useDirectActiveTurns({ * 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目 * 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。 */ - const lastSnapshotSignatureRef = useRef(''); + const lastSnapshotSignatureRef = useRef('[]'); + const requestGenerationRef = useRef(0); const retryTimerRef = useRef(null); useEffect(() => { @@ -60,13 +61,16 @@ export function useDirectActiveTurns({ }, []); const refreshActiveTurns = useCallback(async () => { - if (!invoke) { + if (!enabled || !invoke) { return; } // 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。 if (inFlightRef.current) { return inFlightRef.current; } + const generation = requestGenerationRef.current; + const isCurrent = () => + mountedRef.current && generation === requestGenerationRef.current; const request = (async () => { for ( let attempt = 1; @@ -77,7 +81,7 @@ export function useDirectActiveTurns({ const turns = await invoke( 'list_game_creator_direct_active_turns', ); - if (!mountedRef.current) { + if (!isCurrent()) { return; } const nextTurns = Array.isArray(turns) ? turns : []; @@ -90,6 +94,7 @@ export function useDirectActiveTurns({ inFlightRef.current = null; return; } catch { + if (!isCurrent()) return; if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { await new Promise((resolve) => { retryTimerRef.current = window.setTimeout(() => { @@ -97,22 +102,23 @@ export function useDirectActiveTurns({ resolve(); }, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt); }); + if (!isCurrent()) return; } } } // 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。 - if (mountedRef.current) { + if (isCurrent()) { setSnapshotReadFailed(true); + inFlightRef.current = null; } - inFlightRef.current = null; })(); inFlightRef.current = request; return request; - }, [invoke]); + }, [enabled, invoke]); useEffect(() => { if (!enabled || !invoke) { - lastSnapshotSignatureRef.current = ''; + lastSnapshotSignatureRef.current = '[]'; // 空态也要保持引用稳定:已经空了就不要再换一个新数组。 setActiveTurns((current) => (current.length === 0 ? current : [])); setSnapshotReadFailed((current) => (current ? false : current)); @@ -123,7 +129,12 @@ export function useDirectActiveTurns({ () => void refreshActiveTurns(), Math.max(1_000, pollIntervalMs), ); - return () => window.clearInterval(timer); + return () => { + window.clearInterval(timer); + // 停用或切换读取器后,旧请求不得覆盖新状态,也不能占住新一轮单飞。 + requestGenerationRef.current += 1; + inFlightRef.current = null; + }; }, [enabled, invoke, pollIntervalMs, refreshActiveTurns]); return { activeTurns, refreshActiveTurns, snapshotReadFailed }; 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/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index 92ba12136..a61125c41 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -49,7 +49,7 @@ export function isResourceCanvasPanTarget( return Boolean( target.closest('.game-resource-card') && !target.closest( - 'button:not(.game-resource-card-select), input, textarea, select, a, audio, video', + 'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video', ), ); } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index ea92910fb..de587ba2c 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -7329,7 +7329,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; @@ -7351,9 +7360,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 { @@ -7539,32 +7547,6 @@ iframe.preview-frame { padding-right: 50px; } -.game-resource-card-type-badge { - position: absolute; - top: 8px; - right: 8px; - z-index: 2; - display: inline-flex; - max-width: calc(100% - 16px); - align-items: center; - min-width: 0; - padding: 4px 8px; - overflow: hidden; - border: 1px solid rgb(255 255 255 / 72%); - border-radius: 999px; - background: rgb(75 48 38 / 84%); - color: #fff; - font-size: 10px; - font-weight: 900; - line-height: 1; - letter-spacing: 0.02em; - pointer-events: none; - text-overflow: ellipsis; - white-space: nowrap; - box-shadow: 0 8px 18px rgb(96 62 47 / 20%); - transform: scale(var(--genarrative-image-canvas-inverse-scale, 1)); - transform-origin: top right; -} /* 替换血缘标注(本次会话内有效):源素材卡「已被 … 替换」/ 替换素材卡「替换自 …」。 卡片底部整条是卡面名称(`.game-resource-card-name`),所以血缘角标压在名称条之上; @@ -7718,6 +7700,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 20ac33841..8f8f0fbd1 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 @@ -14,9 +14,11 @@ import { CanvasChromeButton, SelectionOverlay, } from '@genarrative/image-canvas-react'; +import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { AtSign, + Box, Crosshair, Eye, FileCode2, @@ -313,6 +315,8 @@ import { projectResourceCardPreviewVariant, projectResourceCodeTypeLabel, projectResourceJsonPresentation, + projectResourcePathExtension, + projectResourceStructuredPreviewText, } from './resourceCardPreviewModel'; import { ResourceClassificationPanel } from './ResourceClassificationPanel'; import { @@ -345,6 +349,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, @@ -810,6 +817,9 @@ const ResourceCard = memo(function ResourceCard({ cardSize, activeMediaIdentity, onSelect, + onShowInfo, + onChangeType, + infoPressed, onPointerDown, onPointerMove, onPointerUp, @@ -839,6 +849,9 @@ const ResourceCard = memo(function ResourceCard({ cardSize: ResourceCanvasCardSize; activeMediaIdentity: string | null; onSelect: (resourceId: string, options?: { append?: boolean }) => void; + onShowInfo: (resource: ProjectResource) => void; + onChangeType: (assetId: string) => void; + infoPressed: boolean; onPointerDown: ( event: ReactPointerEvent, resource: ProjectResource, @@ -917,6 +930,25 @@ const ResourceCard = memo(function ResourceCard({ */ const isDocumentCard = !jsonPresentation && previewVariant !== null && previewVariant !== 'code'; + /** + * 引擎资源的卡面口径。 + * + * - `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; @@ -946,6 +978,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 ( @@ -1200,13 +1292,19 @@ const ResourceCard = memo(function ResourceCard({ > {resource.label} - - {cardTypeLabel} - + onChangeType(resource.manifestAssetId!) + : undefined + } + onInfoClick={() => onShowInfo(resource)} + /> {lineage ? ( // 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据 // (稳定 id 见卡上的 `data-resource-replaced-by` / `data-resource-replacement-of`)。 @@ -1738,7 +1836,12 @@ export default function ProjectDevelopmentView({ const [characterAnimationPanel, setCharacterAnimationPanel] = useState(null); /** 画布上的只读信息浮层(「信息」动作的落点),与运行页签的信息面板同源。 */ - const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false); + const [resourceInfoResourceId, setResourceInfoResourceId] = useState< + string | null + >(null); + const resourceInfoPanelOpen = + resourceInfoResourceId !== null && + selectedResourceIds[0] === resourceInfoResourceId; const [resourceCanvasMarquee, setResourceCanvasMarquee] = useState(null); /** @@ -1788,6 +1891,14 @@ export default function ProjectDevelopmentView({ const [resourcePanelOpen, setResourcePanelOpen] = useState(false); const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] = useState(null); + /** + * 引擎模型的放大预览浮层身份(同时是当前选中资源的预览身份)。 + * + * 与文档预览浮层一样只在 `resources` 视图、且身份仍等于当前选中资源时渲染; + * 它是**只读预览**,不参与编辑、不写回 manifest。 + */ + const [resourceModelPreviewIdentity, setResourceModelPreviewIdentity] = + useState(null); /** * 「生成素材」浮层的本次放行类型。 * @@ -2207,7 +2318,7 @@ export default function ProjectDevelopmentView({ */ const clearResourceCanvasFocus = useCallback(() => { setSelectedResourceIds([]); - setResourceInfoPanelOpen(false); + setResourceInfoResourceId(null); if (!canDismissResourceCanvasQuickEdit(quickEditPanelRef.current)) { return; } @@ -2632,7 +2743,9 @@ export default function ProjectDevelopmentView({ * 是因为选中本身有多个清空入口(清焦点、切视图、切项目)。 */ useEffect(() => { - setResourceInfoPanelOpen(false); + setResourceInfoResourceId((current) => + current === selectedResourceId ? current : null, + ); }, [selectedResourceId]); const projectVersions = useMemo( () => manifest.versions ?? [], @@ -6727,6 +6840,16 @@ export default function ProjectDevelopmentView({ const showRunUnavailableHint = !runAvailable && !uiEditorRoute; + const showResourceCardInfo = useCallback( + (resource: ProjectResource) => { + handleResourceSelect(resource.id); + setResourceInfoResourceId((current) => + current === resource.id ? null : resource.id, + ); + }, + [handleResourceSelect], + ); + const renderResourceBookCard = useCallback( ( resource: ProjectResource, @@ -6783,6 +6906,9 @@ export default function ProjectDevelopmentView({ } activeMediaIdentity={activeCardMediaIdentity} onSelect={handleResourceSelect} + onShowInfo={showResourceCardInfo} + onChangeType={setResourceTypeAssetId} + infoPressed={resourceInfoResourceId === resource.id} onPointerDown={(event) => handleResourceCardPointerDown(event, resource) } @@ -6809,6 +6935,8 @@ export default function ProjectDevelopmentView({ handleResourceCardPointerMove, handleResourceCardPointerUp, handleResourceSelect, + showResourceCardInfo, + resourceInfoResourceId, resourceCardDragPreview, resourceCardPreviews, resourceReplacementLineageBadgeMap, @@ -6882,10 +7010,18 @@ export default function ProjectDevelopmentView({ ) { setResourceDocumentPreviewIdentity(null); } + if ( + mode !== 'resources' || + uiEditorRoute || + resourceModelPreviewIdentity !== selectedResourcePreviewIdentity + ) { + setResourceModelPreviewIdentity(null); + } }, [ mode, uiEditorRoute, resourceDocumentPreviewIdentity, + resourceModelPreviewIdentity, selectedResourcePreviewIdentity, ]); @@ -6895,6 +7031,12 @@ export default function ProjectDevelopmentView({ return () => protectResourceCardPreview(null); }, [protectResourceCardPreview, resourceDocumentPreviewIdentity]); + useEffect(() => { + if (!resourceModelPreviewIdentity) return; + protectResourceCardPreview(resourceModelPreviewIdentity); + return () => protectResourceCardPreview(null); + }, [protectResourceCardPreview, resourceModelPreviewIdentity]); + /** * 解析一次资源派生的源身份:取项目 revision,必要时把任务产物正规化成正式素材。 * @@ -8912,6 +9054,7 @@ export default function ProjectDevelopmentView({ ) || selectedResourceOpensUiEditor) ? ( 预览 ) : null} + {/* + * 引擎模型:进「放大预览」浮层做交互式视角操作。 + * 判据与卡面同源(`projectResourceCardPreviewKind`), + * 因此不会出现「卡片是模型卡、却没有 3D 入口」的分叉。 + */} + {selectedResource && + selectedResourcePreviewIdentity && + projectResourceCardPreviewKind( + selectedResource, + ) === 'model' ? ( + } + onClick={() => { + stopActiveCardMedia(); + resourceCardPreviews.requestPreview( + selectedResource, + selectedResourcePreviewIdentity, + 'detail', + ); + setResourceModelPreviewIdentity( + selectedResourcePreviewIdentity, + ); + }} + > + 3D 预览 + + ) : null} {selectedResource && selectedResourceOpensUiEditor ? ( 引用 ) : null} - {selectedResource ? ( - } - onClick={() => - setResourceInfoPanelOpen((open) => !open) - } - > - 信息 - - ) : null} {selectedResource?.manifestAssetId ? ( 编辑标签 ) : null} - {selectedResource?.manifestAssetId ? ( - } - onClick={() => - setResourceTypeAssetId( - selectedResource.manifestAssetId, - ) - } - > - 素材类型 - - ) : null} {selectedResource?.manifestAssetId ? ( setResourceInfoPanelOpen(false)} + onClose={() => setResourceInfoResourceId(null)} /> ) : null} {/* @@ -9909,6 +10053,23 @@ export default function ProjectDevelopmentView({ onClose={() => setResourceDocumentPreviewIdentity(null)} /> ) : null} + {mode === 'resources' && + !uiEditorRoute && + selectedResource && + resourceModelPreviewIdentity && + resourceModelPreviewIdentity === selectedResourcePreviewIdentity ? ( + setResourceModelPreviewIdentity(null)} + /> + ) : null} {resourcePanelOpen ? ( ` 与列表符号(保留缩进层级); + * - 去掉强调 / 行内代码 / 图片 / 链接的标记符号,保留可见文字; + * - **不折叠换行** —— 卡面要的是"前几行",压成一行就看不出结构了。 + */ +function stripMarkdownMarkers(content: string): string { + return content + .replace(/^\s*(?:```|~~~).*$/gmu, '') + .replace(/^\s{0,3}#{1,6}\s*/gmu, '') + .replace(/^\s{0,3}>\s?/gmu, '') + .replace(/^\s*[-+*]\s+/gmu, '') + .replace(/!\[([^\]]*)\]\([^)]*\)/gu, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1') + .replace(/`{1,3}([^`]*)`{1,3}/gu, '$1') + .replace(/\*\*([^*]+)\*\*/gu, '$1') + .replace(/__([^_]+)__/gu, '$1') + .replace(/(^|[^*])\*([^*\n]+)\*/gu, '$1$2') + .replace(/(^|[^_])_([^_\n]+)_/gu, '$1$2'); +} + +/** + * 文档卡面的前几行预览文本。 + * + * `isMarkdown` 为 `true` 时先做轻量标记清理;纯文本原样输出。 + * 逐行去掉多余空白(缩进保留最多 2 个空格)并按 `PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH` + * 单行截断,最多取 `PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT` 行。 + */ +export function projectResourceDocumentPreviewText( + content: string, + isMarkdown: boolean, +): string { + const normalized = isMarkdown ? stripMarkdownMarkers(content) : content; + const lines: string[] = []; + for (const rawLine of normalized.split(/\r?\n/u)) { + const line = rawLine.replace(/\t/gu, ' ').replace(/\s+$/u, ''); + const trimmed = line.trimStart(); + if (!trimmed) { + continue; + } + const indent = line.slice(0, line.length - trimmed.length).slice(0, 2); + const text = `${indent}${trimmed}`; + lines.push( + text.length > PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH + ? `${text.slice(0, PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH)}…` + : text, + ); + if (lines.length >= PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT) { + break; + } + } + return lines.join('\n'); +} + +/** + * 引擎(Cocos)序列化资源的**结构摘要**。 + * + * Cocos 把场景、预制体、动画剪辑、材质都序列化成「`{ __type__: 'cc.Xxx', ... }` 对象的 + * 数组」,直接显示原始 JSON 前几行对用户没有信息量(第一行永远是 `[` 和第一个组件的 + * 大段属性)。这里只抽取三类稳定事实:根类型、节点数与类型数、以及动画时长/名称。 + * + * 解析失败或不是这种形状时返回 `null`,调用方回退到「显示前几行文本」,不猜内容。 + */ +export function projectResourceCocosStructureSummary( + content: string, +): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return null; + } + if (!Array.isArray(parsed)) { + return null; + } + const entries = parsed.filter( + (entry): entry is Record => + 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/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 47e386b36..a31d69f89 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -412,7 +412,7 @@ export function registerClientHomeTests() { expect(await findResourceSelectButton('live-hero.png')).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( - await screen.findByRole('button', { name: /版本 1/ }), + await findResourceSelectButton('版本 1'), ).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { @@ -583,7 +583,7 @@ export function registerClientHomeTests() { ).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( - await screen.findByRole('button', { name: /版本 1/ }), + await findResourceSelectButton('版本 1'), ).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { 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 66e90103b..078e9c608 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 @@ -3482,8 +3482,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, ); @@ -3839,21 +3858,10 @@ export function registerProjectWorkbenchFoundationTests() { ]; await openResourceBookCategory('角色与对象'); - fireEvent.click(await findResourceSelectButton('hero.png')); - const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - const toolbarLabels = within(toolbar) - .getAllByRole('button') - .map((button) => button.getAttribute('aria-label') ?? ''); - const infoButton = within(toolbar).getByRole('button', { name: '信息' }); - // 位置固定在「引用」之后、「编辑标签」之前:工具条上的顺序即功能顺序。 - expect(toolbarLabels.indexOf('信息')).toBeGreaterThan( - toolbarLabels.indexOf('引用资源 hero.png'), - ); - expect(toolbarLabels.indexOf('信息')).toBeLessThan( - toolbarLabels.indexOf('编辑标签'), - ); + const infoButton = await screen.findByRole('button', { name: '查看hero.png资源信息' }); expect(infoButton.getAttribute('aria-pressed')).toBe('false'); + // 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。 fireEvent.click(infoButton); const canvasPanel = await screen.findByRole('dialog', { name: '资源信息', @@ -3885,11 +3893,11 @@ export function registerProjectWorkbenchFoundationTests() { // Esc 与快速编辑浮层同一口径:既收浮层也清选中,整个工具条一起收起。 fireEvent.click(await findResourceSelectButton('hero.png')); - const reopenedToolbar = await screen.findByRole('toolbar', { + await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click( - within(reopenedToolbar).getByRole('button', { name: '信息' }), + screen.getByRole('button', { name: '查看hero.png资源信息' }), ); expect( await screen.findByRole('dialog', { name: '资源信息' }), @@ -4097,10 +4105,7 @@ export function registerProjectWorkbenchFoundationTests() { ), ).toBe(false); // 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」), - // 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口, - // 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest - // `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」 - // 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程) + // 并且只渲染宿主编排层真实接通的五个动作;信息与类型由卡片角标承接。 // 「导出」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调, // 不能再渲染成点了没反应的按钮。 const audioToolbar = screen.getByRole('toolbar', { @@ -4115,9 +4120,7 @@ export function registerProjectWorkbenchFoundationTests() { .map((button) => button.getAttribute('aria-label')), ).toEqual([ '引用资源 bgm.mp3', - '信息', '编辑标签', - '素材类型', '重命名', '导出', '删除素材', diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index 521cf634a..f7acdba4e 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -60,10 +60,11 @@ describe('useDirectActiveTurns', () => { { initialProps: { enabled: true } }, ); - await waitFor(() => { - expect(result.current.activeTurns).toEqual([]); - }); const emptySnapshot = result.current.activeTurns; + await act(async () => { + await result.current.refreshActiveTurns(); + }); + expect(result.current.activeTurns).toBe(emptySnapshot); rerender({ enabled: false }); expect(result.current.activeTurns).toBe(emptySnapshot); }); @@ -96,6 +97,59 @@ describe('useDirectActiveTurns', () => { clearTimeoutSpy.mockRestore(); } }); + + it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => { + let complete!: (turns: GameCreatorDirectActiveTurn[]) => void; + const invoke = vi.fn( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const { result, rerender } = renderHook( + ({ enabled }) => + useDirectActiveTurns({ invoke: invoke as never, enabled }), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + const empty = result.current.activeTurns; + await act(async () => { + complete([ACTIVE_TURN]); + await result.current.refreshActiveTurns(); + }); + expect(result.current.activeTurns).toBe(empty); + expect(result.current.snapshotReadFailed).toBe(false); + expect(invoke).toHaveBeenCalledTimes(1); + }); + + it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => { + let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void; + const invoke = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + completeOld = resolve; + }), + ) + .mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]); + const { result, rerender } = renderHook( + ({ enabled }) => + useDirectActiveTurns({ invoke: invoke as never, enabled }), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + rerender({ enabled: true }); + await waitFor(() => + expect(result.current.activeTurns[0]?.runId).toBe('new-run'), + ); + const current = result.current.activeTurns; + await act(async () => { + completeOld([ACTIVE_TURN]); + }); + expect(result.current.activeTurns).toBe(current); + expect(invoke).toHaveBeenCalledTimes(2); + }); }); describe('ActiveProjectRunsPanel', () => { diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 48938bee7..4234d82b8 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -1914,9 +1914,8 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); expect(await cardBadgeText('hero.png')).toBe('角色与对象'); - fireEvent.click(await findResourceSelectButton('hero.png')); - const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + // 未选中资源也能直接从卡片类型角标进入,不依赖工具栏存在。 + fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' })); const dialog = await screen.findByRole('dialog', { name: '设置素材类型', }); @@ -1993,7 +1992,7 @@ describe('project resource live canvas integration', () => { const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); // 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。 - fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' })); await screen.findByRole('dialog', { name: '设置素材类型' }); fireEvent.click(document.body); expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull(); @@ -2073,7 +2072,7 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '信息' })); + fireEvent.click(screen.getByRole('button', { name: '查看hero.png资源信息' })); const infoPanel = await screen.findByRole('dialog', { name: '资源信息' }); // 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index 22018450b..98ee615bc 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -48,6 +48,7 @@ describe('resourceCanvasFocusModel', () => {
+ 信息
@@ -60,6 +61,7 @@ describe('resourceCanvasFocusModel', () => { } for (const id of [ 'play', + 'corner', 'video', 'input', 'editor', 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/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx index 380692d66..cfbc324f9 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx @@ -375,6 +375,13 @@ function renderReplacementWorkbench(options: RenderOptions = {}) { return { invoke, onActiveVersionChange, onPlay, onManifestChange }; } +function toolbarAction(toolbar: HTMLElement, name: string) { + const visible = within(toolbar).queryByRole('button', { name }); + if (visible) return visible; + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + return within(screen.getByRole('group', { name: '更多操作' })).getByRole('button', { name }); +} + async function selectCardAndOpenToolbar(label: string) { await waitFor(() => expect( @@ -538,13 +545,61 @@ function installResourceCardIntersectionObserver() { } describe('版本级资源替换', () => { + it('更多浮层滚轮不平移画布,Escape 只收菜单且换选不残留', async () => { + renderReplacementWorkbench(); + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + const menu = screen.getByRole('group', { name: '更多操作' }); + const viewport = () => document.querySelector('[data-resource-viewport]') + ?.getAttribute('data-resource-viewport'); + const before = viewport(); + expect(before).toBeTruthy(); + const wheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 }); + act(() => { menu.dispatchEvent(wheel); }); + expect(wheel.defaultPrevented).toBe(false); + expect(viewport()).toBe(before); + + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBe(toolbar); + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + fireEvent.click(await findResourceSelectButton('late.png')); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + + const scene = document.querySelector('.game-resource-book-scene')!; + act(() => { + scene.dispatchEvent(new WheelEvent('wheel', { + bubbles: true, cancelable: true, deltaY: 120, clientX: 90, clientY: 70, + })); + }); + expect(viewport()).not.toBe(before); + }); + + it('信息从未选中卡打开并跟随资源身份,普通换选会关闭', async () => { + renderReplacementWorkbench(); + await selectCardAndOpenToolbar('legacy.png'); + const lateInfo = screen.getByRole('button', { name: '查看late.png资源信息' }); + fireEvent.pointerDown(lateInfo, { button: 0 }); + fireEvent.click(lateInfo); + const panel = screen.getByRole('dialog', { name: '资源信息' }); + expect(within(panel).getByText('late.png')).toBeTruthy(); + expect(lateInfo.getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(screen.getByRole('button', { name: '查看legacy.png资源信息' })); + const switched = screen.getByRole('dialog', { name: '资源信息' }); + expect(within(switched).getByText('legacy.png')).toBeTruthy(); + expect(within(switched).queryByText('late.png')).toBeNull(); + fireEvent.click(await findResourceSelectButton('late.png')); + expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); + }); + it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => { const { invoke } = renderReplacementWorkbench(); // 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。 const lateToolbar = await selectCardAndOpenToolbar('late.png'); + fireEvent.mouseEnter(within(lateToolbar).getByRole('button', { name: '更多' })); expect( - within(lateToolbar).queryByRole('button', { name: '替换素材' }), + screen.queryByRole('button', { name: '替换素材' }), ).toBeNull(); expect( within(lateToolbar).getByRole('button', { name: '快速编辑' }), @@ -560,7 +615,7 @@ describe('版本级资源替换', () => { // 被当前版本绑定的素材:入口出现。 const sourceToolbar = await selectCardAndOpenToolbar('legacy.png'); expect( - within(sourceToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(sourceToolbar, '替换素材'), ).not.toBeNull(); }); @@ -569,7 +624,7 @@ describe('版本级资源替换', () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); await waitFor(() => expect( @@ -675,7 +730,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -709,7 +764,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); await waitFor(() => expect( @@ -735,7 +790,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -787,7 +842,7 @@ describe('版本级资源替换', () => { ).length; const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -837,8 +892,11 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - const toolbarLabels = within(toolbar) - .getAllByRole('button') + const deleteButton = toolbarAction(toolbar, '删除素材'); + const toolbarLabels = [ + ...within(toolbar).getAllByRole('button'), + ...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole('button'), + ] .map((button) => button.getAttribute('aria-label') ?? ''); // 末位:在最后一个非破坏性动作(替换素材)之后、共享导出按钮之前。 expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan( @@ -848,9 +906,6 @@ describe('版本级资源替换', () => { toolbarLabels.indexOf('导出'), ); // 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。 - const deleteButton = within(toolbar).getByRole('button', { - name: '删除素材', - }); const divider = deleteButton.previousElementSibling; expect(divider?.getAttribute('class')).toMatch( /(?:image-canvas-editor__floating-toolbar-divider|genarrative-image-canvas__chrome-button)/, @@ -914,7 +969,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '删除素材' })); + fireEvent.click(toolbarAction(toolbar, '删除素材')); const dialog = await screen.findByRole('dialog', { name: '确认删除资源' }); fireEvent.click( within(dialog).getByRole('checkbox', { @@ -976,7 +1031,7 @@ describe('版本级资源替换', () => { ).toBeNull(); // 同一条工具条仍在(只读动作不受 manifest 身份影响),证明不是"整条工具条没渲染"。 expect( - within(toolbar).getByRole('button', { name: '信息' }), + screen.getByRole('button', { name: '查看草稿.png资源信息' }), ).not.toBeNull(); }); @@ -1056,7 +1111,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1084,7 +1139,7 @@ describe('版本级资源替换', () => { const { invoke, onManifestChange } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1155,7 +1210,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1238,7 +1293,7 @@ describe('版本级资源替换', () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1274,7 +1329,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1357,7 +1412,7 @@ describe('版本级资源替换', () => { // 第一次:legacy → final。 const legacyToolbar = await selectCardAndOpenToolbar('legacy.png'); fireEvent.click( - within(legacyToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(legacyToolbar, '替换素材'), ); let dialog = await screen.findByRole('dialog', { name: '选择替换素材', @@ -1377,7 +1432,7 @@ describe('版本级资源替换', () => { // 第二次:final → final.webp(同一个工作台会话内)。 const finalToolbar = await selectCardAndOpenToolbar('final.png'); fireEvent.click( - within(finalToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(finalToolbar, '替换素材'), ); dialog = await screen.findByRole('dialog', { name: '选择替换素材' }); fireEvent.click( diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx index 47471a79b..a8bd7b8cf 100644 --- a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx @@ -84,11 +84,7 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 await act(async () => { await Promise.resolve(); }); - /* - * 无原生 invoke 时 active-turn Hook 只有「空态」这一种状态:空快照引用保持稳定 - * (不再 `setActiveTurns([])` 换新数组),`snapshotReadFailed` 也保持 false。 - * 依赖项一个都没变,工作台只应发布一次。 - */ + // 无原生 invoke 时空快照引用不变,只发布一次。 expect(publications).toHaveLength(1); expect(cleanups).toBe(0); expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe( diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx index 4a366fdaa..29a4dbb5d 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx @@ -59,6 +59,32 @@ test('requires an access token before showing deployments', async () => { expect(await screen.findByText('构建并发布一个分支')).toBeTruthy(); }); +test('prefers the public preview domain when the control plane exposes one', async () => { + vi.mocked(api.listDeployments).mockResolvedValue([ + { + id: '77', + branch: 'feature/public-preview', + resolvedCommit: '1234567890abcdef', + status: 'running', + health: 'healthy', + webPort: 8400, + webUrl: 'http://192.168.35.82:8400', + webPublicUrl: + 'https://preview-63d38d3da6bc9b06.preview.genarrative.world', + createdAt: 1_787_270_400, + updatedAt: 1_787_270_460, + }, + ]); + render(); + + const publicLink = await screen.findByRole('link', { + name: /打开公网预览/u, + }); + expect(publicLink.getAttribute('href')).toBe( + 'https://preview-63d38d3da6bc9b06.preview.genarrative.world', + ); +}); + test('submits a branch with an optional commit hash', async () => { const user = userEvent.setup(); render(); diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx index ec5e30773..ea23c3836 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx @@ -797,6 +797,16 @@ function DeploymentCard({
+ {deployment.webUrl && deployment.webPublicUrl ? ( + + 打开公网预览 + + ) : null} {deployment.webUrl ? ( .<后缀>,留空则只展示内网地址。 +GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN=preview.genarrative.world GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE=/var/lib/genarrative/preview-deployer/state.json GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR=/opt/genarrative/preview-deployer/web GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE=false diff --git a/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md new file mode 100644 index 000000000..6a8060348 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md @@ -0,0 +1,16 @@ +# AGC 资源菜单收纳实施计划 + +对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md),Issue #409,产品已确认方案 A。 + +## PR #410 CI 修复 + +以远端合并提交 959beebf 为基线:修复菜单文件 import 排序、Web 角标结构断言、活动回合空快照与晚到请求竞态;窗口发布次数断言对齐稳定快照合同。原生 HTTP scope 检查对齐官方 updater 当前权限,不恢复退役 OSS 白名单;Rust 图集测试补齐显式切片模式与 strict schema 字段,不放宽正式校验。按故障项定向测试后运行前端全套及原生契约检查;Rust 使用独立 target,实际未执行的检查必须单独列出。推送需再次确认。 + +本地修复验证:`npm test` 342 个文件通过(4137 项通过、37 项跳过),窗口与空快照最后一次定向复验 9 项通过;`lint:eslint`、根目录/AGC 类型检查、原生 contract 检查、Rust fmt、编码、文档索引与 diff 检查通过。Rust 工具目录 schema 用例及后台平台美术生成用例均在 Windows 独立 target 下通过;生成用例同时检查真实 mock 请求中的 grid、2×2 参数与响应匹配。未执行全量 Rust 分片、Linux CI、生产服务或真实客户端手感验收。 + +1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。 +2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。 +3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。 +4. 并行执行定向 Vitest、AGC 类型检查、编码与文档索引检查,再自审整体调用链。 + +风险:portal 浮层点击外部判定、缩放角标与拖拽冲突、原测试依赖完整工具栏。回滚仅撤销本分支 UI 与文档修改;无数据迁移。首个检查点为组件用例通过,第二个为工作台集成与类型检查。真实客户端未测则明确保留待验收状态。 diff --git a/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md new file mode 100644 index 000000000..1c2ac43c9 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md @@ -0,0 +1,31 @@ +# AGC 资源菜单收纳 + +- Version: 1 +- Status: implemented,本地自动化通过,待真实客户端验收 +- Date: 2026-09-17 +- Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md + +## 范围与评审 + +仅调整前端入口和临时浮层状态,不修改资源命令、权限、持久化、后端或 Web 默认菜单行为。主菜单保留前 5 项,其余悬停/点击向上展开;类型与信息下沉卡片。产品已选定方案 A,边界与既有资源操作合同无冲突,按单里程碑实施。对应 Issue #409,已获得创建 Issue 与本地实施授权;推送、PR 与飞书写入仍需单独确认。 + +## 验收 + +- 动作顺序、禁用状态和回调保持一致,少于等于 5 项不出现更多。 +- 更多支持鼠标移入浮层、点击、键盘、外部关闭与视口约束。 +- 卡片类型、信息入口不触发拖拽;未选中卡直接看信息,换选不残留旧信息。 +- 定向组件与工作台测试、类型检查、编码/文档索引/diff 检查通过;真实客户端视觉和触摸板手感单独验收。 + +## 产品结论与验收待办 + +正式实现采用方案 A;方案 B 不进入工作台,A/B 演示只留在忽略目录供本地参考。 + +完整工作台回归的 2 项失败已定位为新增信息按钮导致“版本 1”模糊匹配重复,改为精确查询资源选中按钮,完整重跑通过。 + +## 验收证据 + +- `appSurface.test.ts`:450 项通过、20 项跳过。 +- 收纳/卡片、Web 工具栏/卡片、资源类型实时链路、替换/重命名、浮层判据、动作可用性:178 项通过;追加真实工作台「更多滚轮不平移画布、Escape 仅收菜单、换选收起」与「跨卡片信息身份」2 项通过。 +- 根目录类型检查与 AGC 类型检查(含 skill-pack / config 检查)通过;编码、文档索引与 diff 检查通过。 +- 浏览器中真实组件预览已检查上方展开、执行回调后关闭、卡片信息入口;预览仅用演示数据,不替代真实 AGC 客户端。 +- 剩余:真实客户端、原生保存对话框、触摸板操作人工验收。未推送,未创建 PR,未更新飞书。 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..1b57435a0 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,主站前端保持现有行为。 @@ -8849,3 +8864,30 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界:Deploy 阶段在远端 dev / release agent 执行,不受该上限约束。调整只动这两处:`systemctl set-property / revert jenkins.service`、`docker update --cpus= gitea-runner` 加同步 compose(备份 `/opt/gitea-stack/compose.yml.bak-<时间戳>`)。 - 验证:限速后 `Genarrative-Full-Build-And-Deploy` #289 / #290 SUCCESS;采样期 Jenkins 峰值 10.2~10.5 核、限流不足 2s(可忽略),runner 峰值 12.07 核且持续出现 throttling,整机回落到 2.6%~19.8%。 - 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。 + +## 2026-09-17 Jenkins 公网入口 jenkins.genarrative.world 复用 router 反向隧道口径 + +- 背景:Jenkins controller 实际与 Gitea 同机运行在 `genarrative-station`(`jenkins.service`,`--httpPort=8080 --prefix=/jenkins`,`JENKINS_HOME=/var/lib/jenkins`),此前只有内网入口 `http://192.168.35.82:8080/jenkins/`;`router.genarrative.world` 已有「dev Nginx → dev loopback → station 反向隧道」的成熟口径。 +- 决策:沿用 router 口径,不新增网关组件。`genarrative-station` 的 `gitea-reverse-tunnel.service` 增加 `-R 127.0.0.1:18085:127.0.0.1:8080`(dev loopback `18085` → station Jenkins `127.0.0.1:8080`);dev 新增 `/etc/nginx/conf.d/jenkins.genarrative.world.conf`:`80` 只做 ACME webroot 与 `301`,`443` 用 Certbot 证书反代 `http://127.0.0.1:18085` 并保留 `Upgrade` / `X-Forwarded-*`;证书按 router 口径用 `certbot certonly --webroot -w /var/www/html -d jenkins.genarrative.world --renew-hook 'systemctl reload nginx'` 申请。 +- 路径口径:Jenkins 固定 `--prefix=/jenkins`,域名根路径 `302` 到 `https://jenkins.genarrative.world/jenkins/login`,`/jenkins` 补斜杠,其余未带前缀路径 `302` 到 `/jenkins$request_uri`;证书不复制到 Pingora 私有目录,公网 `80/443` 仍由 dev Nginx 监听。 +- 边界:本次只暴露 HTTP/HTTPS UI,`slaveAgentPort` 保持 `-1`(agent 继续由 Jenkins 用 SSH launcher 连 dev / release),不改 Jenkins `jenkinsUrl` 与鉴权策略;Jenkins 登录页因此进入公网可达面,访问控制继续依赖 Jenkins 自身账号体系。 +- 验证:dev `nginx -t` 与 `systemctl reload nginx` 通过;`curl -sI https://jenkins.genarrative.world/` 返回 `302 /jenkins/login`、`/jenkins/login` 返回 `200`、登录页静态资源 `200`、`http://` 入口 `301`;Let's Encrypt 证书 `CN=jenkins.genarrative.world` 到期 `2026-12-16`;公网探测 `82.157.175.59` 仍只开放 `80/443/22`。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。 + +## 2026-09-17 预览部署控制面公网入口 build.genarrative.world + +- 背景:多人内网预览控制面(`preview-deployer-server` + `shared/Genarrative-Preview-Deployer` Job)此前只在内网 `http://192.168.35.82/build/` 提供,2026-08-15 决策明确「不配置公网域名」;本次要求给它加公网入口。 +- 决策:沿用 router / Jenkins 同一口径新增 `build.genarrative.world` 作为控制面公网入口,并把 `preview.genarrative.world` 作为 `*.preview.genarrative.world` 规划的父域名先落一张落地页(实例本身仍只在内网)。station `gitea-reverse-tunnel.service` 增加 `-R 127.0.0.1:18086:127.0.0.1:8410`;dev 新增 `/etc/nginx/conf.d/build.genarrative.world.conf`(`80` ACME+`301`,`443` 把 `/build/`、`/api/preview-deployer/` 反代到 `127.0.0.1:18086`,`/` 跳 `/build/`,公网侧 `proxy_cookie_flags ~ secure`)与 `/etc/nginx/conf.d/preview.genarrative.world.conf`(单域名证书 + 落地页)。 +- 白名单:控制面按 `Host` 精确匹配、对非 GET 的 `/api/*` 精确匹配 `Origin`,因此同步改为 `GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS=192.168.35.82,build.genarrative.world`、`GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS=http://192.168.35.82,https://build.genarrative.world`;`GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE` 保持 `false`(内网 HTTP 入口继续可用),公网 cookie 的 `Secure` 由 dev nginx 强制。重启 `genarrative-preview-deployer.service` 会清空内存会话,内网用户需重新输入口令。 +- 边界:本次只暴露控制面(触发/查看构建、卸载),预览实例不暴露;`preview.genarrative.world` 没有通配记录,实例地址仍是内网 `http://192.168.35.82:84xx`,控制面页面展示的 `webUrl` 也仍是内网地址。要变成公网实例地址,需要 `*.preview.genarrative.world` 通配证书(只能 DNS-01)、station 侧按 Host 分发和页面 URL 口径改造。 +- 验证:`nginx -t` 与 reload 通过;`https://build.genarrative.world/` `302 → /build/`、`/build/` `200`、SPA 资源 `200`、`/api/preview-deployer/session` 返回 `{"authenticated":false}`;错误或缺失 `Origin` 的 POST `403`、错误口令 `401`、字段名不符 `422`;两个新域名证书到期 `2026-12-16`;内网 `Host: 192.168.35.82` 仍 `200`、未知 Host `403`;jenkins / dev / git 入口回归正常。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[Jenkins容器预览部署控制面技术方案](../../technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md)。 + +## 2026-09-17 预览控制面增加公网预览地址口径(代码已实现,待随控制面发布) + +- 背景:控制面页面此前只展示内网地址 `http://192.168.35.82:`;公网通配域名 `*.preview.genarrative.world` 已解析到 dev,需要页面能显示对应的公网入口。 +- 决策:公网地址由控制面自己派生,不接受 Jenkins 产物或状态文件提供的任意地址。`preview-deployer-server` 新增可选配置 `GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN`(如 `preview.genarrative.world`,只接受小写字母、数字、短横线和点号,不带协议与端口),并在公开 DTO 新增 `webPublicUrl`:仅当记录已有内网 `webUrl` 时取 `https://.`(实例 ID 仍由分支派生,形如 `preview-<16位hex>`),卸载时与 `webUrl` / `webPort` 一起清空;状态文件里的旧值在加载时被重新派生覆盖。 +- 前端:`apps/preview-deployer-web` 在存在公网地址时把「打开公网预览」作为主入口,内网地址降级为次级链接;未配置时行为与之前一致。 +- 上线依赖(本次未完成):`*.preview.genarrative.world` 通配证书(Let's Encrypt 通配只能走 DNS-01,域名在 DNSPod,certbot 无官方插件,需要 DNSPod API Token 配合 acme.sh)、station 侧按 Host 分发到 `84xx` 端口、dev 通配 vhost 与隧道;控制面本体需在 station 用 `scripts/deploy/preview-deployer-install.sh` 重建发布。 +- 验证:`cargo test -p preview-deployer-server`(13 项)、`apps/preview-deployer-web` vitest(13 项,含新增公网地址用例)、`npx tsc --noEmit`、`npm run preview-deployer:web:build`(`PREVIEW_DEPLOYER_WEB_BASE=/build/`)、`npm run check:preview-deployer`、`npm run check:encoding`、`git diff --check` 全部通过。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[Jenkins容器预览部署控制面技术方案](../../technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 52b39d70a..db2e03c14 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 历史不能按工具条目切页再按消息推进游标 @@ -21,6 +31,8 @@ JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡 工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。 +活动回合快照的初始签名须与初始空数组一致,首次异步返回空数组不能额外换引用。停用、重新启用或切换读取器时应使旧请求失效,避免晚到结果覆盖新快照;测试需控制 Promise 完成时机,不能用“初始数组已为空”当作请求已结束。无原生读取器时窗口只发布一次空状态。 + ## 2026-09-17 工具 schema 声明的上限与真实校验不一致,会表现成「agent 调不动这个功能」 - **现象**:用户反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。查工具目录时两个工具都在(`agc_edit_image`、`agc_create_or_derive_resource`),图片快速编辑在真实项目日志里还有成功记录;但 agent 侧写一句正常长度的背景音乐描述就失败,而客户端 UI 用同一个提示词却只是被截断加提示。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 98662a4e2..6f64a3000 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -16,6 +16,7 @@ ## 开发中 +- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。 - 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。 - UI 开发优先复用现有公共组件;跨页面或跨端重复的视觉/交互模式应沉淀到 `packages/shared`,由现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。 - AGC 当前 Agent 与策划 Agent 的消息层级共用 `packages/shared` 的 `AgentMessageContent`:正文使用 `body`,思考、中间输出与工具调用使用 `process`;宿主不按 Agent 类型重新定义过程字号和颜色,错误状态保留语义色。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 0e21b256b..c09b2f654 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -30,6 +30,7 @@ ## 资源画布交互与工作台状态同步 +- 活动回合轮询的初始空快照与后续空结果保持同一引用;停用或切换读取器使旧请求失效,晚到快照不得恢复已停用的活动回合或覆盖新轮询结果。无原生读取器时窗口只发布一次空状态,不通过额外空数组触发重复发布。 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。 - 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。 - 画布接管的右键手势不弹出原生菜单;输入框、媒体操作、工具条和独立浮层不被画布抢占。指针取消、捕获丢失或窗口失焦后终止平移,不能继续跟随指针。 @@ -39,6 +40,8 @@ ## 资源卡选中工具栏与导出 +- AGC 选中工具栏按既有动作顺序最多直接显示前 5 项(不计分隔线),剩余动作进入「更多」。悬停、点击及键盘均可展开独立纵向浮层,优先向上展开,窗口顶边空间不足时向下避让;浮层限制在窗口内,超高时自行滚动,不带动画布。动作执行、点击外部、Escape 或换选资源后关闭;禁用状态和原处理链路保持不变。Web 美术画布默认不折叠。 +- 「素材类型」与「信息」不占工具栏名额,改为资源卡右上角的类型标签和信息圆钮,与 Web 美术画布共用卡片控件。未选中卡片可直接打开信息;类型入口仅对 manifest 资产可用。控件不触发卡片拖拽或多选,信息面板仍复用运行页签的字段。 - 共享选中工具栏按实际显示的快速编辑、编辑动作、改造、导出与宿主动作组生成分隔线;空组不产生分隔线,不依赖宿主 CSS 隐藏重复线。 - AGC 所有具有本地文件路径的素材都显示带文字的「导出」按钮,位于工具栏末组的「删除素材」之前,两者之间不插入分隔线;「重命名」继续保留在前面的常规动作组。工具栏宽度上限为 `min(92vw, 800px)`,窄屏仍可横向滚动。图片、视频、音频、动画、UI、文档及其它文件共用 `isResourceCanvasExportable`,不按媒体类型限制导出;无文件路径及虚拟项目版本不提供文件导出入口。 - 导出继续复用 `saveProjectResourcesToDisk`:原生保存对话框选择路径,`save_local_project_asset_file` 复制原始文件字节,不转图片、不重编码、不另建 IPC。后端继续校验源文件、敏感路径和目标路径;取消不写文件,失败通过工作台提示。 @@ -170,7 +173,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/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 0b5d624d2..f13c743d5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -783,6 +783,8 @@ npm run container:down `npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。 多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 Jenkins,Jenkins checkout 仍保留最终复核。每个分支使用稳定的内部 `deploymentId` 和独立 Compose project,Web 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放;页面记录 ID 使用 Jenkins 构建编号。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康;构建详情链接固定使用局域网 Jenkins 地址,不暴露 loopback 地址。失败/取消且不可卸载的记录保留 7 天,停止记录保留 30 天,仍可卸载的失败记录不会自动清理。安装资产为 `deploy/systemd/genarrative-preview-deployer.service`、`deploy/env/preview-deployer.env.example` 和 `deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`。 +Jenkins controller 与 Gitea 同机运行在 `genarrative-station`,除内网入口 `http://192.168.35.82:8080/jenkins/` 外,公网入口为 `https://jenkins.genarrative.world/jenkins/`:dev 的 `/etc/nginx/conf.d/jenkins.genarrative.world.conf` 把 `443` 反代到 `http://127.0.0.1:18085`,该 loopback 端口由同机 `gitea-reverse-tunnel.service` 新增的 `-R 127.0.0.1:18085:127.0.0.1:8080` 落到 station 的 `jenkins.service`(`--prefix=/jenkins`,因此域名根路径 `302` 到 `/jenkins/login`)。排障顺序:dev `ss -tlnp | grep 18085` 必须有 `sshd` 监听,`curl -sI https://jenkins.genarrative.world/jenkins/login` 必须 `200`,`systemctl status gitea-reverse-tunnel.service` 必须 `active`,且公网 `80/443` 仍由 Nginx 监听;`slaveAgentPort=-1` 保持关闭,agent 仍走 SSH launcher,不新增入库端口。证书按 router 口径用 Certbot webroot(`/var/www/html`)维护,续期 hook 为 `systemctl reload nginx`。 +预览部署控制面的公网入口为 `https://build.genarrative.world/build/`(`/` 与 `/build` 分别 `302`/`301` 到 `/build/`,API 走同源 `/api/preview-deployer/`):dev 的 `/etc/nginx/conf.d/build.genarrative.world.conf` 反代到 `127.0.0.1:18086`,该 loopback 端口由 station `gitea-reverse-tunnel.service` 的 `-R 127.0.0.1:18086:127.0.0.1:8410` 落到控制面 `preview-deployer-server`。控制面按 `Host` 精确匹配白名单、对非 GET 的 `/api/*` 精确匹配 `Origin`,公网域名必须同时出现在 `GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS` 和 `GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS` 中;公网 cookie 的 `Secure` 由 dev nginx 的 `proxy_cookie_flags ~ secure` 强制,因此 `GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE` 保持 `false`,内网 `http://192.168.35.82/build/` 的登录态不受影响。排障顺序:dev `ss -tlnp | grep 18086` 有 `sshd` 监听;`curl -s https://build.genarrative.world/api/preview-deployer/session` 返回 `{"authenticated":false}`;缺失或错误 `Origin` 的 POST 必须 `403`,错误口令必须 `401`。`preview.genarrative.world` 当前只是通配规划下的落地页,预览实例仍只在内网 `http://192.168.35.82:84xx`;要暴露实例需要 `*.preview.genarrative.world` 通配证书(只能 DNS-01)、station 侧按 Host 分发和页面 `webUrl` 口径改造。 隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并且只使用 unsupported job 验证 worker claim / fail 回写,不覆盖 BgFilter 成功、失败或 fallback 链路,也不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。 独立 BgFilter worker 的本机全进程验证先运行 `cargo build -p api-server --manifest-path server-rs/Cargo.toml`,再依次运行 `npm run bgfilter-worker:smoke-test`、`npm run bgfilter-worker:load-smoke` 和 `npm run bgfilter-worker:fault-smoke`。三条命令只使用动态 loopback 端口、假 OSS 签名配置和本地 mock provider;不会读取仓库 `.env*` 或请求真实 BgFilter / OSS。自定义或 WSL binary 通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定。当前 fault 范围包含 overload、queue deadline、两类 HTTP 状态顺序重试结果,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功;慢读、大响应、父侧客户端断连与 SIGTERM 排空另行验证。 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", diff --git a/packages/shared/src/components/CanvasCardCornerActions.tsx b/packages/shared/src/components/CanvasCardCornerActions.tsx new file mode 100644 index 000000000..9996e8923 --- /dev/null +++ b/packages/shared/src/components/CanvasCardCornerActions.tsx @@ -0,0 +1,68 @@ +import { Info } from 'lucide-react'; +import type { CSSProperties, Ref } from 'react'; + +import { PlatformIconButton } from './PlatformIconButton'; + +/** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */ +export function CanvasCardCornerActions({ + kindLabel, + kindAriaLabel, + kindClassName, + infoLabel, + style, + kindRef, + onKindClick, + onInfoClick, + infoPressed, +}: { + kindLabel?: string | null; + kindAriaLabel?: string; + kindClassName?: string; + infoLabel: string; + style?: CSSProperties; + kindRef?: Ref; + onKindClick?: () => void; + onInfoClick: () => void; + infoPressed?: boolean; +}) { + return ( + event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {kindLabel ? ( + { + if (onKindClick && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + onKindClick(); + } + }} + > + {kindLabel} + + ) : null} + + ); +} diff --git a/packages/shared/src/components/OverflowActions.test.tsx b/packages/shared/src/components/OverflowActions.test.tsx new file mode 100644 index 000000000..85ad1d46a --- /dev/null +++ b/packages/shared/src/components/OverflowActions.test.tsx @@ -0,0 +1,207 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CanvasCardCornerActions } from './CanvasCardCornerActions'; +import { OverflowActions } from './OverflowActions'; + +afterEach(cleanup); +describe('操作收纳与卡片角标', () => { + it('具名入口可收纳全部动作,并优先向上展开', () => { + render( + + + + , + ); + const trigger = screen.getByRole('button', { name: '素材处理' }); + expect(screen.getAllByRole('button')).toHaveLength(1); + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + left: 200, + right: 300, + top: 350, + bottom: 380, + width: 100, + height: 30, + x: 200, + y: 350, + toJSON: () => ({}), + }); + const height = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockReturnValue(100); + fireEvent.mouseEnter(trigger); + const group = screen.getByRole('group', { name: '素材处理操作' }); + expect(parseFloat(group.style.top)).toBeLessThan(350); + expect( + within(group) + .getAllByRole('button') + .map((b) => b.textContent), + ).toEqual(['快速编辑', '改造']); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(screen.queryByRole('group')).toBeNull(); + height.mockRestore(); + vi.restoreAllMocks(); + }); + it('前五项可见,跳过分隔符和空 fragment,悬停展示剩余项并执行原回调', () => { + const click = vi.fn(); + render( + + <> + + , + ); + expect(screen.getAllByRole('button')).toHaveLength(6); + expect(screen.queryByText('六')).toBeNull(); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + const group = screen.getByRole('group', { name: '更多操作' }); + expect( + within(group) + .getAllByRole('button') + .map((b) => b.textContent), + ).toEqual(['六', '七']); + expect((screen.getByText('七') as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByText('六')); + expect(click).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('group')).toBeNull(); + }); + it('悬停后点击仍展开、移入浮层不消失、外部关闭,Escape 恢复焦点', () => { + vi.useFakeTimers(); + render( + + + + , + ); + const more = screen.getByRole('button', { name: '更多' }); + fireEvent.mouseEnter(more); + fireEvent.click(more); + fireEvent.mouseLeave(more); + fireEvent.mouseEnter(screen.getByRole('group')); + vi.advanceTimersByTime(200); + expect(screen.getByText('二')).toBeTruthy(); + fireEvent.keyDown(screen.getByText('二'), { key: 'Escape' }); + expect(screen.queryByRole('group')).toBeNull(); + expect(document.activeElement).toBe(more); + fireEvent.click(more); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole('group')).toBeNull(); + vi.useRealTimers(); + }); + it('不溢出不显示更多,默认保持 Web 原样', () => { + const view = render( + + + + , + ); + expect(screen.queryByText('更多')).toBeNull(); + view.rerender( + + {Array.from({ length: 10 }, (_, i) => ( + + ))} + , + ); + expect(screen.getAllByRole('button')).toHaveLength(10); + }); + it('动作减少至不溢出后关闭浮层,恢复动作不会自动重开', () => { + const view = render( + + + + , + ); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + expect(screen.getByRole('group')).toBeTruthy(); + view.rerender( + + + , + ); + expect(screen.queryByRole('group')).toBeNull(); + view.rerender( + + + + , + ); + expect(screen.queryByRole('group')).toBeNull(); + }); + it('浮层在窗口右下边界向上展开,方向键跳过禁用项', () => { + render( + + + + + + , + ); + const more = screen.getByRole('button', { name: '更多' }); + vi.spyOn(more, 'getBoundingClientRect').mockReturnValue({ + left: window.innerWidth - 35, + right: window.innerWidth, + top: window.innerHeight - 40, + bottom: window.innerHeight - 10, + width: 35, + height: 30, + x: 0, + y: 0, + toJSON: () => ({}), + }); + // jsdom 没有真实布局,提供浮层测量以验证向上定位。 + const height = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockReturnValue(200); + fireEvent.click(more); + const panel = screen.getByRole('group'); + expect(parseFloat(panel.style.top)).toBeLessThan(window.innerHeight - 40); + screen.getByText('二').focus(); + fireEvent.keyDown(screen.getByText('二'), { key: 'ArrowDown' }); + expect(document.activeElement).toBe(screen.getByText('四')); + fireEvent.keyDown(screen.getByText('四'), { key: 'Home' }); + expect(document.activeElement).toBe(screen.getByText('二')); + height.mockRestore(); + vi.restoreAllMocks(); + }); + it('卡片角标阻断指针和键盘冒泡,类型及信息分别执行', () => { + const parent = vi.fn(), + kind = vi.fn(), + info = vi.fn(); + render( +
+ +
, + ); + const label = screen.getByRole('button', { name: '素材类型' }); + fireEvent.pointerDown(label); + fireEvent.click(label); + fireEvent.keyDown(label, { key: 'Enter' }); + fireEvent.click(screen.getByRole('button', { name: '资源信息' })); + expect(kind).toHaveBeenCalledTimes(2); + expect(info).toHaveBeenCalledOnce(); + expect(parent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/OverflowActions.tsx b/packages/shared/src/components/OverflowActions.tsx new file mode 100644 index 000000000..23c4d222a --- /dev/null +++ b/packages/shared/src/components/OverflowActions.tsx @@ -0,0 +1,237 @@ +import { + Children, + cloneElement, + Fragment, + isValidElement, + type ReactNode, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; + +type ActionProps = { children?: ReactNode; 'aria-hidden'?: boolean | 'true' }; + +function flatten(nodes: ReactNode, prefix = ''): ReactNode[] { + return Children.toArray(nodes).flatMap((node, index) => + isValidElement(node) && node.type === Fragment + ? flatten(node.props.children, `${prefix}${index}.`) + : [ + isValidElement(node) + ? cloneElement(node, { key: `${prefix}${index}` }) + : node, + ], + ); +} + +function isDivider(node: ReactNode) { + return ( + isValidElement(node) && + (node.props['aria-hidden'] === true || node.props['aria-hidden'] === 'true') + ); +} + +/** 只负责展示收纳;动作权限、禁用与执行仍由调用方提供。 */ +export function OverflowActions({ + children, + maxVisible = Infinity, + label = '更多', +}: { + children: ReactNode; + maxVisible?: number; + label?: string; +}) { + const [open, setOpen] = useState(false); + const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 320 }); + const trigger = useRef(null); + const panel = useRef(null); + const timer = useRef | null>(null); + const id = useId(); + const limit = Number.isFinite(maxVisible) + ? Math.max(0, Math.floor(maxVisible)) + : Infinity; + const nodes = flatten(children); + let count = 0; + const split = nodes.findIndex((node) => !isDivider(node) && ++count > limit); + const primary = split < 0 ? nodes : nodes.slice(0, split); + while (primary.length && isDivider(primary[primary.length - 1])) + primary.pop(); + const overflow = + split < 0 ? [] : nodes.slice(split).filter((node) => !isDivider(node)); + useEffect(() => { + if (overflow.length === 0) setOpen(false); + }, [overflow.length]); + const cancelClose = () => { + if (timer.current !== null) clearTimeout(timer.current); + timer.current = null; + }; + const show = () => { + cancelClose(); + setOpen(true); + }; + const scheduleClose = () => { + cancelClose(); + timer.current = setTimeout(() => { + if (!panel.current?.contains(document.activeElement)) setOpen(false); + }, 150); + }; + useEffect( + () => () => { + if (timer.current !== null) clearTimeout(timer.current); + }, + [], + ); + useLayoutEffect(() => { + if (!open || !overflow.length) return; + const update = () => { + const anchor = trigger.current?.getBoundingClientRect(); + if (!anchor) return; + const width = panel.current?.getBoundingClientRect().width ?? 200; + const height = panel.current?.scrollHeight ?? 320; + const below = window.innerHeight - anchor.bottom - 12; + const above = anchor.top - 12; + // 优先上展,留出卡片预览;窗口顶边空间不足时才向下避让。 + const down = above < 80 && below > above; + const maxHeight = Math.max(40, Math.min(360, down ? below : above)); + setPosition({ + left: Math.max( + 8, + Math.min(anchor.right - width, window.innerWidth - width - 8), + ), + top: down + ? anchor.bottom + 4 + : Math.max(8, anchor.top - Math.min(height, maxHeight) - 4), + maxHeight, + }); + }; + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + }; + }, [open, overflow.length, children]); + useEffect(() => { + if (!open) return; + const outside = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !trigger.current?.contains(event.target) && + !panel.current?.contains(event.target) + ) { + setOpen(false); + } + }; + const escape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + trigger.current?.focus(); + }; + document.addEventListener('pointerdown', outside); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('pointerdown', outside); + document.removeEventListener('keydown', escape); + }; + }, [open]); + if (!overflow.length) return <>{children}; + return ( + <> + {primary} + + {open + ? createPortal( +
event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + if ((event.target as Element).closest('button:not(:disabled)')) + setOpen(false); + }} + onBlur={(event) => { + if ( + !event.currentTarget.contains(event.relatedTarget) && + event.relatedTarget !== trigger.current + ) + setOpen(false); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + trigger.current?.focus(); + } + if ( + ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) + ) { + event.preventDefault(); + const buttons = Array.from( + event.currentTarget.querySelectorAll( + 'button:not(:disabled)', + ), + ); + const index = buttons.indexOf( + document.activeElement as HTMLButtonElement, + ); + const next = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? buttons.length - 1 + : (index + + (event.key === 'ArrowDown' ? 1 : -1) + + buttons.length) % + buttons.length; + buttons[next]?.focus(); + } + }} + > + {overflow} +
, + document.body, + ) + : null} + + ); +} diff --git a/packages/shared/src/components/index.ts b/packages/shared/src/components/index.ts index f2b1cee94..327a40757 100644 --- a/packages/shared/src/components/index.ts +++ b/packages/shared/src/components/index.ts @@ -220,6 +220,8 @@ export { Textarea } from './ui/textarea'; // existing application adapters while keeping the public API product-neutral. export type { AgentMessageTone } from './AgentMessageContent'; export { AgentMessageContent } from './AgentMessageContent'; +export { CanvasCardCornerActions } from './CanvasCardCornerActions'; +export { OverflowActions } from './OverflowActions'; export type { ButtonProps as PlatformButtonProps, SwitchProps as PlatformSwitchProps, diff --git a/packages/shared/src/components/styles.css b/packages/shared/src/components/styles.css index 2c969af7d..aa9301eec 100644 --- a/packages/shared/src/components/styles.css +++ b/packages/shared/src/components/styles.css @@ -1310,3 +1310,126 @@ textarea.genarrative-ui-text-field__control { border-radius: 1.25rem 1.25rem 0 0; } } +/* 画布卡片角标:缩放由宿主传入,两个入口保持一致的视觉尺寸。 */ +.shared-canvas-card-corners { + position: absolute; + top: 6px; + right: 6px; + z-index: 3; + display: flex; + align-items: center; + gap: 4px; + max-width: calc(100% - 12px); + transform: scale( + var( + --image-canvas-editor-inverse-scale, + var(--genarrative-image-canvas-inverse-scale, 1) + ) + ); + transform-origin: top right; +} +.shared-canvas-card-kind { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 4px 8px; + border: 1px solid rgb(255 255 255 / 72%); + border-radius: 999px; + background: rgb(199 101 61 / 92%); + color: #fff; + font-size: 11px; + font-weight: 850; + line-height: 1; +} +.shared-canvas-card-kind[role='button'] { + cursor: pointer; +} +.shared-canvas-card-info { + display: grid; + flex: 0 0 22px; + width: 22px; + height: 22px; + place-items: center; + border: 1px solid rgb(255 255 255 / 42%); + border-radius: 50%; + background: rgb(199 101 61 / 92%); + color: #fff; + cursor: pointer; +} +.shared-canvas-card-info:hover, +.shared-canvas-card-kind[role='button']:hover { + background: #b95d3a; +} +.shared-canvas-card-corners [role='button']:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} +.shared-overflow-trigger { + flex: none; + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 30px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + cursor: pointer; +} +.shared-overflow-trigger:hover, +.shared-overflow-trigger[aria-expanded='true'] { + background: #f4e7df; +} +.shared-overflow-panel { + position: fixed; + z-index: 1500; + display: flex; + flex-direction: column; + gap: 3px; + width: 200px; + max-width: calc(100vw - 16px); + overflow-y: auto; + overscroll-behavior: contain; + padding: 6px; + border: 1px solid #e7d5c8; + border-radius: 9px; + background: #fff; + color: #4b3026; + box-shadow: 0 10px 28px rgb(52 30 22 / 18%); +} +.shared-overflow-panel > button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + flex: 0 0 auto; + width: 100%; + min-height: 34px; + padding: 6px 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +.shared-overflow-panel > button:hover, +.shared-overflow-panel > button:focus-visible { + background: #f4e7df; +} +.shared-overflow-panel > button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.shared-overflow-panel + > button.genarrative-image-canvas__chrome-button:not( + .genarrative-image-canvas__chrome-button--with-label + )::after { + content: attr(aria-label); +} diff --git a/server-rs/crates/preview-deployer-server/src/config.rs b/server-rs/crates/preview-deployer-server/src/config.rs index f693697bb..ff8d637ab 100644 --- a/server-rs/crates/preview-deployer-server/src/config.rs +++ b/server-rs/crates/preview-deployer-server/src/config.rs @@ -18,6 +18,7 @@ pub struct Config { pub allowed_hosts: Vec, pub allowed_origins: Vec, pub preview_web_host: String, + pub preview_web_domain: Option, pub secure_cookie: bool, pub static_dir: Option, pub state_file: PathBuf, @@ -49,6 +50,7 @@ impl fmt::Debug for Config { .field("allowed_hosts", &self.allowed_hosts) .field("allowed_origins", &self.allowed_origins) .field("preview_web_host", &self.preview_web_host) + .field("preview_web_domain", &self.preview_web_domain) .field("secure_cookie", &self.secure_cookie) .field("static_dir", &self.static_dir) .field("state_file", &self.state_file) @@ -116,6 +118,15 @@ impl Config { .to_string(), ); } + let preview_web_domain = env::var("GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(|value| { + validate_preview_web_domain(&value) + .map_err(|reason| format!("GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN {reason}")) + }) + .transpose()?; for origin in &allowed_origins { let parsed = Url::parse(origin).map_err(|_| format!("无效 allowed origin: {origin}"))?; @@ -162,6 +173,7 @@ impl Config { allowed_hosts, allowed_origins, preview_web_host, + preview_web_domain, secure_cookie, static_dir, state_file, @@ -217,6 +229,28 @@ fn validate_state_file(path: &std::path::Path) -> Result<(), String> { Ok(()) } +pub(crate) fn validate_preview_web_domain(value: &str) -> Result { + if value.len() > 253 || !value.contains('.') { + return Err("必须是不带协议和端口的 DNS 域名".to_string()); + } + for label in value.split('.') { + if label.is_empty() || label.len() > 63 { + return Err("必须是不带协议和端口的 DNS 域名".to_string()); + } + let bytes = label.as_bytes(); + if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' { + return Err("每段标签不能以短横线开头或结尾".to_string()); + } + if !bytes + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') + { + return Err("只允许小写字母、数字、短横线和点号".to_string()); + } + } + Ok(value.to_string()) +} + fn required(name: &str) -> Result { env::var(name) .ok() diff --git a/server-rs/crates/preview-deployer-server/src/lib.rs b/server-rs/crates/preview-deployer-server/src/lib.rs index 223255b1b..e0165799b 100644 --- a/server-rs/crates/preview-deployer-server/src/lib.rs +++ b/server-rs/crates/preview-deployer-server/src/lib.rs @@ -115,6 +115,8 @@ pub struct Deployment { pub web_port: Option, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_public_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub jenkins_build_url: Option, pub created_at: u64, @@ -633,6 +635,7 @@ async fn create_deployment( health: HealthStatus::Pending, web_port: None, web_url: None, + web_public_url: None, jenkins_build_url: None, created_at: now, updated_at: now, @@ -959,6 +962,14 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { if let Some(value) = result.web_port { record.public.web_port = Some(value); } + record.public.web_public_url = if record.public.web_url.is_some() { + public_web_url( + state.config.preview_web_domain.as_deref(), + &record.instance_id, + ) + } else { + None + }; if let Some(value) = result.health { record.public.health = value; } @@ -1005,6 +1016,7 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { record.public.health = HealthStatus::Unknown; record.public.web_port = None; record.public.web_url = None; + record.public.web_public_url = None; record.public.can_uninstall = false; if record.public.message.is_none() { record.public.message = Some("预览实例已卸载".to_string()); @@ -1090,6 +1102,11 @@ fn load_deployments(config: &Config) -> Result if record.public.web_port.is_none() { record.public.web_port = url_port; } + record.public.web_public_url = if record.public.web_url.is_some() { + public_web_url(config.preview_web_domain.as_deref(), &record.instance_id) + } else { + None + }; if deployments .insert(record.instance_id.clone(), record) .is_some() @@ -1326,6 +1343,16 @@ fn map_artifact_health(value: &str) -> Option { } } +// 公网入口由控制面自己派生,主机名固定为「实例 ID + 配置的预览域名」, +// 不接受 Jenkins 产物或状态文件提供的任意地址。 +fn public_web_url(domain: Option<&str>, instance_id: &str) -> Option { + let domain = domain?; + if validate_deployment_id(instance_id).is_err() { + return None; + } + Some(format!("https://{instance_id}.{domain}")) +} + fn is_safe_web_url(value: &str, expected_host: &str) -> bool { url::Url::parse(value).is_ok_and(|url| { url.scheme() == "http" diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs index 98b2b5511..7874d5169 100644 --- a/server-rs/crates/preview-deployer-server/src/tests.rs +++ b/server-rs/crates/preview-deployer-server/src/tests.rs @@ -171,6 +171,7 @@ fn test_config(jenkins_base_url: Url) -> Config { allowed_hosts: vec![HOST.to_string()], allowed_origins: vec![ORIGIN.to_string()], preview_web_host: "192.168.35.82".to_string(), + preview_web_domain: Some("preview.genarrative.world".to_string()), secure_cookie: false, static_dir: None, state_file: std::env::temp_dir().join(format!( @@ -378,6 +379,10 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { ); assert_eq!(deployment.health, super::HealthStatus::Healthy); assert_eq!(deployment.web_port, Some(8400)); + assert_eq!( + deployment.web_public_url.as_deref(), + Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world") + ); assert_eq!( deployment.web_url.as_deref(), Some("http://192.168.35.82:8400") @@ -436,6 +441,7 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { assert_eq!(deployment.health, super::HealthStatus::Unknown); assert_eq!(deployment.web_port, None); assert_eq!(deployment.web_url, None); + assert_eq!(deployment.web_public_url, None); assert!(!deployment.can_uninstall); let list_request = axum::http::Request::builder() @@ -605,6 +611,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() { health: super::HealthStatus::Pending, web_port: None, web_url: None, + web_public_url: None, jenkins_build_url: None, created_at: now, updated_at: now, @@ -652,6 +659,40 @@ async fn wait_for_status( Err(()) } +#[test] +fn public_web_url_is_derived_from_instance_id_and_configured_domain() { + assert_eq!( + super::public_web_url( + Some("preview.genarrative.world"), + "preview-63d38d3da6bc9b06" + ) + .as_deref(), + Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world") + ); + assert_eq!( + super::public_web_url(None, "preview-63d38d3da6bc9b06"), + None + ); + assert_eq!( + super::public_web_url(Some("preview.genarrative.world"), "feature/demo"), + None + ); +} + +#[test] +fn preview_web_domain_config_is_strict() { + assert!(super::config::validate_preview_web_domain("preview.genarrative.world").is_ok()); + assert!(super::config::validate_preview_web_domain("preview.genarrative.world:443").is_err()); + assert!( + super::config::validate_preview_web_domain("https://preview.genarrative.world").is_err() + ); + assert!(super::config::validate_preview_web_domain("Preview.Genarrative.World").is_err()); + assert!(super::config::validate_preview_web_domain("preview").is_err()); + assert!(super::config::validate_preview_web_domain("-preview.genarrative.world").is_err()); + assert!(super::config::validate_preview_web_domain("preview..genarrative.world").is_err()); + assert!(super::config::validate_preview_web_domain("").is_err()); +} + #[test] fn branch_commit_and_web_url_validation_are_strict() { assert!(super::validate_branch("feature/preview-ui").is_ok()); @@ -758,6 +799,7 @@ async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_reta health: super::HealthStatus::Unknown, web_port: None, web_url: None, + web_public_url: None, jenkins_build_url: None, created_at: old, updated_at: old, @@ -781,6 +823,7 @@ async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_reta health: super::HealthStatus::Unknown, web_port: Some(8401), web_url: Some("http://192.168.35.82:8401".to_string()), + web_public_url: None, jenkins_build_url: None, created_at: old, updated_at: old, diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index cdbef119d..1c3c42bf5 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -3875,9 +3875,7 @@ describe('ImageCanvasEditorView generation integration', () => { if (!metadataCornerButton) { throw new Error('metadata corner button should exist'); } - expect(metadataCornerButton.className).toContain( - 'image-canvas-editor__metadata-corner', - ); + expect(metadataCornerButton.className).toContain('shared-canvas-card-info'); fireEvent.click(metadataCornerButton); const metadataDialog = screen.getByRole('dialog', { name: '图片信息' }); diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 32d777569..92b947c88 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1387,9 +1387,7 @@ describe('ImageCanvasEditorView', () => { const infoButton = screen.getByRole('button', { name: '查看拼图素材图片信息', }); - expect(infoButton.className).toContain( - 'image-canvas-editor__metadata-corner', - ); + expect(infoButton.className).toContain('shared-canvas-card-info'); fireEvent.click(infoButton); const infoPanel = screen.getByRole('dialog', { name: '图片信息' }); diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 8b5a462fc..a2f6a607e 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -70,6 +70,50 @@ function renderSelectedToolbar( } describe('ImageCanvasSelectedLayerToolbarView', () => { + it('AGC 收纳包含宿主末组,换选后关闭旧资源更多菜单', () => { + const props = renderSelectedToolbar({ + maxVisibleActions: 5, + supportedActions: new Set([ + 'quick-edit', + 'character-animation', + 'download', + ]), + downloadLabel: '导出', + extraActions: ( + <> + + + + + ), + endActions: , + }); + const toolbar = screen.getByRole('toolbar'); + expect( + within(toolbar) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual(['快速编辑', '生成动画', '引用', '编辑标签', '重命名', '更多 ▴']); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + const group = screen.getByRole('group', { name: '更多操作' }); + expect( + within(group) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual(['导出', '删除素材']); + fireEvent.click(within(group).getByRole('button', { name: '导出' })); + expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer); + cleanup(); + const view = render(); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + view.rerender( + , + ); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + }); it('常规动作与末组分别分隔,导出紧邻删除且位于删除之前', () => { const props = renderSelectedToolbar({ supportedActions: new Set(['quick-edit', 'download']), diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index 6587025c0..f61909628 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -1,4 +1,5 @@ import { CanvasChromeButton } from '@genarrative/image-canvas-react'; +import { OverflowActions } from '@genarrative/shared/components'; import { Crop, Download, @@ -38,6 +39,7 @@ export type ImageCanvasSelectedToolbarAction = | 'download'; type ImageCanvasSelectedLayerToolbarViewProps = { + maxVisibleActions?: number; /** * 宿主显式声明的可用动作集合。 * @@ -78,6 +80,7 @@ function hasToolbarActions(actions: ReactNode): boolean { } export function ImageCanvasSelectedLayerToolbarView({ + maxVisibleActions, supportedActions = null, extraActions, endActions, @@ -148,22 +151,24 @@ export function ImageCanvasSelectedLayerToolbarView({ aria-label="素材工具栏" onPointerDown={(event) => event.stopPropagation()} > - {canRedraw ? ( - } - onClick={() => onOpenRedrawPanel(selectedLayer)} - > - 改造 - - ) : null} - {canRedraw && hasExtraActions ? divider : null} - {extraActions} - {(canRedraw || hasExtraActions) && hasEndActions ? divider : null} - {downloadAction} - {endActions} + + {canRedraw ? ( + } + onClick={() => onOpenRedrawPanel(selectedLayer)} + > + 改造 + + ) : null} + {canRedraw && hasExtraActions ? divider : null} + {extraActions} + {(canRedraw || hasExtraActions) && hasEndActions ? divider : null} + {downloadAction} + {endActions} +
); } @@ -208,170 +213,172 @@ export function ImageCanvasSelectedLayerToolbarView({ aria-label="图片工具栏" onPointerDown={(event) => event.stopPropagation()} > - {showQuickEdit ? ( - } - onClick={() => onOpenQuickEditPanel(selectedLayer)} - > - 快速编辑 - - ) : null} - {showQuickEdit && hasEditingActions ? divider : null} - {showCropExpand ? ( - onOpenCropExpandPanel(selectedLayer)} - /> - ) : null} - {showRemoveBackground ? ( - onRemoveBackground(selectedLayer)} - /> - ) : null} - {showPerfectPixel ? ( - - ) : ( - - ) - } - // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 - // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 - // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 - // 与相邻的拆分图集按钮保持同一套门禁。 - disabled={ - isPersistingAssetKind || - isPerfectPixelProcessing || - isPerfectPixelPendingConfirmation - } - aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} - onClick={() => onPerfectPixel(selectedLayer)} - > - - {isPersistingAssetKind - ? '保存中' - : isPerfectPixelProcessing - ? '处理中' - : isPerfectPixelPendingConfirmation - ? '待确认' - : '完美像素'} - - - ) : null} - {showSplitIconSpritesheet ? ( - - ) : ( - - ) - } - disabled={isPersistingAssetKind || isSplittingIconSpritesheet} - aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet} - onClick={() => onSplitIconSpritesheet(selectedLayer)} - > - - {isPersistingAssetKind - ? '保存中' - : isSplittingIconSpritesheet - ? '拆图中' - : '拆分图集'} - - - ) : null} - {showExtractUiDesign ? ( - } - onClick={() => onExtractUiDesignAssets(selectedLayer)} - > - 提取素材 - - ) : null} - {showCharacterAnimation ? ( - } - onClick={() => onOpenCharacterAnimationPanel(selectedLayer)} - > - 生成动画 - - ) : null} - {canRedraw ? ( - <> - {showQuickEdit || hasEditingActions ? divider : null} + + {showQuickEdit ? ( } - onClick={() => onOpenRedrawPanel(selectedLayer)} + label="快速编辑" + title="快速编辑" + icon={} + onClick={() => onOpenQuickEditPanel(selectedLayer)} > - 改造 + 快速编辑 - - ) : null} - {(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions - ? divider - : null} - {extraActions} - {(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) && - hasEndActions - ? divider - : null} - {downloadAction} - {endActions} + ) : null} + {showQuickEdit && hasEditingActions ? divider : null} + {showCropExpand ? ( + onOpenCropExpandPanel(selectedLayer)} + /> + ) : null} + {showRemoveBackground ? ( + onRemoveBackground(selectedLayer)} + /> + ) : null} + {showPerfectPixel ? ( + + ) : ( + + ) + } + // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 + // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 + // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 + // 与相邻的拆分图集按钮保持同一套门禁。 + disabled={ + isPersistingAssetKind || + isPerfectPixelProcessing || + isPerfectPixelPendingConfirmation + } + aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} + onClick={() => onPerfectPixel(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isPerfectPixelProcessing + ? '处理中' + : isPerfectPixelPendingConfirmation + ? '待确认' + : '完美像素'} + + + ) : null} + {showSplitIconSpritesheet ? ( + + ) : ( + + ) + } + disabled={isPersistingAssetKind || isSplittingIconSpritesheet} + aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet} + onClick={() => onSplitIconSpritesheet(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isSplittingIconSpritesheet + ? '拆图中' + : '拆分图集'} + + + ) : null} + {showExtractUiDesign ? ( + } + onClick={() => onExtractUiDesignAssets(selectedLayer)} + > + 提取素材 + + ) : null} + {showCharacterAnimation ? ( + } + onClick={() => onOpenCharacterAnimationPanel(selectedLayer)} + > + 生成动画 + + ) : null} + {canRedraw ? ( + <> + {showQuickEdit || hasEditingActions ? divider : null} + } + onClick={() => onOpenRedrawPanel(selectedLayer)} + > + 改造 + + + ) : null} + {(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions + ? divider + : null} + {extraActions} + {(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) && + hasEndActions + ? divider + : null} + {downloadAction} + {endActions} + ); } diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index ebc0b3b75..bd03cd7ab 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -814,7 +814,7 @@ describe('ImageCanvasWorldView', () => { expect( ( - within(layerButton).getByText('角色') as HTMLElement + within(layerButton).getByText('角色').parentElement as HTMLElement ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( @@ -825,6 +825,7 @@ describe('ImageCanvasWorldView', () => { expect( within(layerButton) .getByRole('button', { name: '查看角色主图图片信息' }) + .parentElement! .style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( @@ -1118,12 +1119,8 @@ describe('ImageCanvasWorldView', () => { name: '查看角色主图图片信息', }); - expect(badge.className).toContain( - 'image-canvas-editor__kind-badge--beside-info', - ); - expect(metadataButton.className).not.toContain( - 'image-canvas-editor__metadata-corner--beside-kind', - ); + expect(badge.parentElement?.className).toBe('shared-canvas-card-corners'); + expect(badge.nextElementSibling).toBe(metadataButton); }); it('keeps the layer type menu scrollable and closes it when clicking outside', () => { diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 42315907a..a9e1e9622 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -3,13 +3,13 @@ import { LayerRenderer, SelectionOverlay, } from '@genarrative/image-canvas-react'; +import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { AppWindow, Clapperboard, ClipboardList, Grid2X2, ImageIcon, - Info, Megaphone, Mountain, Music, @@ -44,7 +44,6 @@ import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; -import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { @@ -367,17 +366,6 @@ function handleLayerKeyboardActivation(event: ReactKeyboardEvent) { event.currentTarget.click(); } -function handleLayerTagKeyboardActivation( - event: ReactKeyboardEvent, -) { - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.click(); -} - function stopMediaControlKeyPropagation( event: ReactKeyboardEvent, ) { @@ -1012,38 +1000,19 @@ const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({ mediaTransform={mediaTransform} /> )} - {kindLabel ? ( - event.stopPropagation()} - onClick={(event) => { - event.stopPropagation(); - handlers.setOpenLayerKindMenuId((currentId) => - currentId === layer.id ? null : layer.id, - ); - }} - onKeyDown={handleLayerTagKeyboardActivation} - > - {kindLabel} - - ) : null} - } + { - event.stopPropagation(); - handlers.onOpenLayerMetadata(layer); - }} - onPointerDown={(event) => event.stopPropagation()} + onKindClick={() => + handlers.setOpenLayerKindMenuId((currentId) => + currentId === layer.id ? null : layer.id, + ) + } + onInfoClick={() => handlers.onOpenLayerMetadata(layer)} /> {isHovered && layer.mediaType !== 'audio' ? (