Compare commits

..

2 Commits

Author SHA1 Message Date
lhk229 5c648ab9c7 为美术包增加图片自动转码
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m38s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / Backend tests (pull_request) Successful in 4m11s
Project CI / Frontend tests (pull_request) Successful in 2m2s
Project CI / Native shell tests (pull_request) Successful in 6m14s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m30s
Project CI / Repository checks (pull_request) Successful in 1m52s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m46s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m51s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m23s
Project CI / AI game creator shell Rust crates (push) Successful in 57s
Project CI / Backend tests (push) Successful in 3m52s
Project CI / Frontend tests (push) Successful in 2m4s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m52s
Project CI / Native shell tests (push) Successful in 6m0s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m4s
Project CI / AI game creator shell web tests (push) Successful in 1m20s
Project CI / Repository checks (push) Successful in 1m51s
美术包在本地提交前将 JPEG 和 WebP 转为 PNG,保留有效 PNG 原字节
统一最终图片的扩展名、媒体类型与恢复摘要,保留原有透明度和大小校验
覆盖损坏下载重试、账本保留和清理、重复恢复零生成请求,并同步规范与排障记录
验证通过 106 项定向测试及格式、编码、文档索引和差异检查
2026-09-27 12:52:10 +00:00
lhk229 155f0d316a 新增 external v1 游戏场景生成路由并迁移 AGC 美术包背景阶段 (#516)
Project CI / AI game creator shell Rust smoke (push) Successful in 1m50s
Project CI / AI game creator shell Rust crates (push) Successful in 1m15s
Project CI / Backend tests (push) Successful in 4m29s
Project CI / Native shell tests (push) Successful in 6m27s
Project CI / Frontend tests (push) Successful in 1m59s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 9m12s
Project CI / AI game creator shell web tests (push) Successful in 1m33s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m3s
Project CI / Repository checks (push) Successful in 2m1s
- api-server 新增 POST /api/external/v1/editor/scenes/generations,复用 editor:image-generate scope、幂等键和站内场景生图队列
- editor_project.rs 抽取站内与外部共用的场景生图 payload 构造函数,站内 handler 改为调用共享函数
- 同步更新 external v1 OpenAPI 契约与 MCP 派生排除标记,登记 docs/README.md 索引
- AGC 客户端美术包背景阶段改走新场景路由,stylePreset 固定 custom + customStyle 保留现有风格文案
- external_generation_state 快照与 recovery_scan 账本白名单支持新场景路由
- direct_runtime 背景资源识别同时兼容新场景路由与旧通用路由,新增旧路由回放测试
- 更新 genarrative-external-editor-api skill 参考文档
- 新增主规范、里程碑规范与实施计划三份 SDD 文档

Reviewed-on: #516
2026-09-27 18:39:01 +08:00
47 changed files with 1667 additions and 4914 deletions
@@ -30,6 +30,7 @@ The hosted MCP offers the following tools. Choose the task tool when its action
| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
| `POST /api/external/v1/editor/scenes/generations` | structured game-scene generation (no hosted MCP tool yet) | `generate_external_editor_scene` |
| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
@@ -89,6 +90,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Capability | POST path | Required body fields | Common optional body fields |
| ------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Game scene | `/api/external/v1/editor/scenes/generations` | `sceneContent`, `stylePreset` | `customStyle` (required when `stylePreset="custom"`), `model`, `aspectRatio`, `imageSize`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
@@ -98,7 +100,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
Poll all nine through:
Poll all ten through:
```text
GET /api/external/v1/generations/{operationId}
@@ -140,7 +142,7 @@ The icon-spritesheet primary `referenceId` is intentionally stricter than ordina
Use OpenAPI as the final authority; these common values are a routing aid:
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
- Game scenes must use the dedicated structured route `POST /api/external/v1/editor/scenes/generations` (`sceneContent` + `stylePreset`; `customStyle` required for `custom`). Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. The scene route assembles the full provider prompt server-side and never accepts a caller-assembled `prompt`.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`.
@@ -148,7 +148,7 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep
## Art Spec and Image Request
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued.
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
-2
View File
@@ -57,8 +57,6 @@ temp*build*/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/
/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
/apps/mobile-shell/.expo/
+4
View File
@@ -0,0 +1,4 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
+1 -5
View File
@@ -13,10 +13,6 @@
"skill-pack:check": "node scripts/check-skill-pack.mjs",
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
"bundled-resources:check": "node scripts/check-package-layout.mjs",
"bundled-resources:sync": "node scripts/check-package-layout.mjs --write",
"bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs",
"bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs",
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
"config": "node scripts/game-creator-config-wizard.mjs",
@@ -28,7 +24,7 @@
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
"agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs",
"agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill",
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs"
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs"
},
"dependencies": {
"@cubone/react-file-manager": "^1.35.0",
@@ -18,7 +18,6 @@ import {
resolveReleaseChannel,
} from './channel-identity.mjs';
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
import { prepareBundledResources } from './prepare-bundled-resources.mjs';
import { stageNodeRuntime } from './stage-node-runtime.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -432,30 +431,10 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
return configPath;
}
/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。
export function stageBundledResources(
target,
{ prepare = prepareBundledResources } = {},
) {
const summaries = prepare({
target,
features: new Set(defaultEditorFeatures(target)),
profile: 'release',
log: (line) => console.log(`[ai-game-creator-shell] ${line}`),
});
for (const summary of summaries) {
console.log(`[ai-game-creator-shell] ${summary}`);
}
}
export function runTauriBuild(
args = [],
context = resolveReleaseContext(args),
{
spawn = spawnSync,
stageRuntime = stageNodeRuntime,
stageBundled = stageBundledResources,
} = {},
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
) {
if (
explicitBuildTarget(args) &&
@@ -465,10 +444,7 @@ export function runTauriBuild(
}
const tauriArguments = buildTauriBuildArguments(args, context.target);
const { channel, target } = context;
if (!args.includes('--no-bundle')) {
stageRuntime(target);
stageBundled(target);
}
if (!args.includes('--no-bundle')) stageRuntime(target);
const configPath = writeChannelConfigFile(
channel,
target,
@@ -363,8 +363,6 @@ test('packaged renderer receives the same channel as the updater manifest', () =
// 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的
// Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。
stageRuntime: () => {},
// 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。
stageBundled: () => {},
spawn: (_binary, _args, options) => {
spawnOptions = options;
return { status: 0 };
@@ -458,8 +456,6 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
seenContexts.push(context);
runTauriBuild(args, context, {
stageRuntime: () => {},
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
stageBundled: () => {},
spawn: (_binary, command) => {
const configIndex = command.lastIndexOf('--config');
const config = JSON.parse(
@@ -709,7 +705,6 @@ test('Windows remains the default and explicit Windows overrides macOS environme
context,
{
stageRuntime: () => {},
stageBundled: () => {},
spawn: (_binary, command) => {
assert.ok(
command.includes(
@@ -823,10 +818,6 @@ test('release stages Node before Tauri and injects its resource mapping only for
assert.equal(target, windowsTarget);
events.push('stage');
},
stageBundled(target) {
assert.equal(target, windowsTarget);
events.push('bundled');
},
spawn(_binary, args) {
events.push('build');
const config = JSON.parse(
@@ -838,14 +829,11 @@ test('release stages Node before Tauri and injects its resource mapping only for
return { status: 0 };
},
});
assert.deepEqual(events, ['stage', 'bundled', 'build']);
assert.deepEqual(events, ['stage', 'build']);
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
stageRuntime() {
assert.fail('no-bundle must not stage resources');
},
stageBundled() {
assert.fail('no-bundle must not stage bundled resources');
},
spawn(_binary, args) {
const config = JSON.parse(
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
@@ -860,9 +848,6 @@ test('release stages Node before Tauri and injects its resource mapping only for
stageRuntime() {
throw new Error('missing runtime');
},
stageBundled() {
assert.fail('invalid runtime must prevent bundled staging');
},
spawn() {
assert.fail('invalid runtime must prevent build');
},
@@ -975,8 +960,6 @@ for (const channel of ['release', 'beta-2']) {
);
runTauriBuild([`--target=${target}`], context, {
stageRuntime: () => {},
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
stageBundled: () => {},
spawn: (_binary, command) => {
const config = JSON.parse(
readFileSync(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,6 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs';
import {
defaultEditorFeatures,
withDefaultCargoFeatures,
@@ -11,10 +10,6 @@ import {
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
} from './dev-port.mjs';
import {
prepareBundledResources,
supportedHostTarget,
} from './prepare-bundled-resources.mjs';
import {
isAiGameCreatorServer,
preflightExistingVite,
@@ -72,29 +67,6 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
return withDefaultCargoFeatures(argv, features);
}
/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。
/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。
function prepareBundledResourcesBeforeTauri(
features = readDevCargoFeatures(),
{ prepare = prepareBundledResources, log = console.log } = {},
) {
const target = supportedHostTarget();
if (!target) {
log(
'[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备',
);
return;
}
const summaries = prepare({
target,
features: new Set(features),
log: (line) => log(`[ai-game-creator-shell] ${line}`),
});
for (const summary of summaries) {
log(`[ai-game-creator-shell] ${summary}`);
}
}
function spawnTauriCli(argv, { env = process.env } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
@@ -103,17 +75,6 @@ function spawnTauriCli(argv, { env = process.env } = {}) {
});
}
/// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的
/// `rustc-wrapper`(本地常见为 sccache)会在这里生效。本地 sccache daemon 状态
/// 一旦损坏,`cargo` 的首次 rustc 探测就会失败并阻断整个 AGC 启动;因此这里复用
/// `npm run dev` 的本地 Rust 环境规则,由脚本而不是本机 Cargo 配置决定 wrapper。
function buildTauriDevProcessEnv(endpoint, env = process.env) {
return buildLocalRustProcessEnv({
...withAgcDevEndpointEnv(endpoint, env),
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
});
}
async function runTauriDev(
argv = process.argv.slice(2),
{
@@ -123,7 +84,6 @@ async function runTauriDev(
spawnCli = spawnTauriCli,
waitForCli = waitForChildTermination,
terminateTree = terminateChildTree,
prepareResources = prepareBundledResourcesBeforeTauri,
} = {},
) {
const endpoint = await resolveDevEndpoint();
@@ -170,14 +130,15 @@ async function runTauriDev(
shutdownRequested.then(() => false),
]);
if (!prepared || shutdownSignal) return 1;
const devFeatures = readDevCargoFeatures();
prepareResources(devFeatures);
const tauriArguments = buildTauriArguments(
withDevCargoFeatures(argv, devFeatures),
withDevCargoFeatures(argv),
endpoint.url,
);
child = spawnCli(tauriArguments, {
env: buildTauriDevProcessEnv(endpoint),
env: {
...withAgcDevEndpointEnv(endpoint),
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
},
});
const childResult = waitForCli(child);
const outcome = await Promise.race([
@@ -264,9 +225,7 @@ function isDirectModuleExecution() {
export {
buildTauriArguments,
buildTauriDevProcessEnv,
isDirectModuleExecution,
prepareBundledResourcesBeforeTauri,
runTauriDev,
spawnTauriCli,
withDevCargoFeatures,
@@ -0,0 +1,4 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
File diff suppressed because it is too large Load Diff
@@ -1,18 +1,8 @@
//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。
//!
//! 布局、组件白名单与版本常量来自唯一声明 `build_support/package-layout.json`
//! (Rust 侧经 `build_support/package-layout.generated.rs` 取得编译期常量,
//! 由 `scripts/check-package-layout.mjs` 生成并在门禁中校验一致)。
//! 本模块只读声明,不写任何随包资源。
// 共享声明模块:构建脚本、运行期与测试各自只用到其中一部分,未用到的入口不算缺陷。
#[allow(dead_code)]
#[path = "package_layout.rs"]
pub(crate) mod package_layout;
pub const VERSION: &str = package_layout::CODEX_VERSION;
pub const CLI_VERSION: &str = package_layout::CODEX_CLI_VERSION;
pub const SCHEMA: &str = package_layout::CODEX_MANIFEST_SCHEMA;
pub const VERSION: &str = "0.155.1";
pub const CLI_VERSION: &str = "codex-cli 0.155.1";
pub const SCHEMA: &str = "genarrative-codex-sidecar.v2";
#[derive(Clone, Copy, Debug)]
pub struct Layout {
@@ -22,13 +12,46 @@ pub struct Layout {
pub files: &'static [&'static str],
}
const WINDOWS_FILES: &[&str] = &[
"bin/codex.exe",
"bin/codex-code-mode-host.exe",
"codex-path/rg.exe",
"codex-resources/codex-command-runner.exe",
"codex-resources/codex-windows-sandbox-setup.exe",
"codex-package.json",
];
const MAC_FILES: &[&str] = &[
"bin/codex",
"bin/codex-code-mode-host",
"codex-path/rg",
"codex-resources/zsh/bin/zsh",
"codex-package.json",
];
pub fn for_target(target: &str) -> Option<Layout> {
package_layout::codex_target(target).map(|declared| Layout {
platform: declared.platform,
directory: declared.directory,
executable: declared.executable,
files: declared.files,
})
match target {
"x86_64-pc-windows-msvc" => Some(Layout {
platform: "win32-x64",
directory: "win-x64",
executable: "bin/codex.exe",
files: WINDOWS_FILES,
}),
"aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout {
platform: if target.starts_with("aarch64") {
"darwin-arm64"
} else {
"darwin-x64"
},
directory: if target.starts_with("aarch64") {
"mac-native/darwin-arm64"
} else {
"mac-native/darwin-x64"
},
executable: "bin/codex",
files: MAC_FILES,
}),
_ => None,
}
}
#[cfg(test)]
@@ -57,11 +80,4 @@ mod tests {
assert!(for_target("aarch64-pc-windows-msvc").is_none());
assert!(for_target("x86_64-unknown-linux-gnu").is_none());
}
#[test]
fn constants_come_from_the_shared_declaration() {
assert_eq!(VERSION, "0.155.1");
assert_eq!(CLI_VERSION, format!("codex-cli {VERSION}"));
assert_eq!(SCHEMA, "genarrative-codex-sidecar.v2");
}
}
@@ -0,0 +1,57 @@
//! 随包阶段的原生包元数据校验,不进入运行时生产模块。
use super::codex_bundle::{Layout, VERSION};
pub fn validate_package_metadata(
metadata: &serde_json::Value,
target: &str,
layout: Layout,
) -> Result<(), String> {
if metadata["layoutVersion"] == 1
&& metadata["version"] == VERSION
&& metadata["target"] == target
&& metadata["entrypoint"] == layout.executable
&& metadata["resourcesDir"] == "codex-resources"
&& metadata["pathDir"] == "codex-path"
{
Ok(())
} else {
Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}"))
}
}
#[cfg(test)]
mod tests {
use super::super::codex_bundle::for_target;
use super::*;
#[test]
fn metadata_rejects_version_architecture_and_layout_drift() {
let target = "aarch64-apple-darwin";
let layout = for_target(target).unwrap();
let valid = serde_json::json!({
"layoutVersion": 1,
"version": VERSION,
"target": target,
"entrypoint": "bin/codex",
"resourcesDir": "codex-resources",
"pathDir": "codex-path",
});
assert!(validate_package_metadata(&valid, target, layout).is_ok());
for (key, value) in [
("layoutVersion", serde_json::json!(2)),
("version", serde_json::json!("0.0.0")),
("target", serde_json::json!("x86_64-apple-darwin")),
("entrypoint", serde_json::json!("bin/codex.exe")),
("resourcesDir", serde_json::json!("../private")),
("pathDir", serde_json::json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(
validate_package_metadata(&invalid, target, layout).is_err(),
"{key}"
);
}
}
}
@@ -1,13 +1,13 @@
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
// 共享随包资源声明:Godot 随包文件清单与构建期校验共用同一份来源。
#[allow(dead_code)]
#[path = "package_layout.rs"]
mod package_layout;
pub const BUNDLE_FILES: &[&str] = package_layout::GODOT_BUNDLE_FILES;
pub const BUNDLE_FILES: [&str; 4] = [
"bin/win-x64/agc_godot_editor.dll",
"bin/win-x64/metadata.json",
"vendor/LICENSE.txt",
"vendor/provenance.json",
];
fn plain_metadata(path: &Path) -> Result<fs::Metadata, String> {
let metadata = fs::symlink_metadata(path)
@@ -74,6 +74,39 @@ pub fn validate(root: &Path) -> Result<Vec<(&'static str, Vec<u8>)>, String> {
Ok(files)
}
pub fn stage(root: &Path, destination: &Path, target: &str, enabled: bool) -> Result<(), String> {
if target != "x86_64-pc-windows-msvc" || !enabled {
return Ok(());
}
for (relative, bytes) in validate(root)? {
let path = destination.join(relative);
fs::create_dir_all(path.parent().expect("Godot resource parent"))
.map_err(|error| format!("创建 Godot 资源目录失败:{error}"))?;
fs::write(&path, bytes).map_err(|error| format!("写入 Godot 资源失败:{error}"))?;
}
Ok(())
}
pub fn source_files(root: &Path) -> Result<Vec<PathBuf>, String> {
plain_metadata(root)?;
let mut sources = Vec::new();
for entry in fs::read_dir(root).map_err(|error| format!("读取 Godot 源码失败:{error}"))?
{
let entry = entry.map_err(|error| format!("读取 Godot 源码目录项失败:{error}"))?;
if matches!(entry.file_name().to_str(), Some("bin" | ".build")) {
continue;
}
let metadata = plain_metadata(&entry.path())?;
if metadata.is_dir() {
sources.extend(source_files(&entry.path())?);
} else if metadata.is_file() {
sources.push(entry.path());
}
}
sources.sort();
Ok(sources)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -99,4 +132,84 @@ mod tests {
)
.unwrap();
}
#[test]
fn stage_only_verified_windows_runtime_and_not_build_inputs() {
let source = tempfile::tempdir().unwrap();
let destination = tempfile::tempdir().unwrap();
fixture(source.path());
fs::write(source.path().join("bridge.gd"), "source").unwrap();
fs::write(source.path().join("bin/win-x64/extra.dll"), "excluded").unwrap();
stage(
source.path(),
destination.path(),
"x86_64-pc-windows-msvc",
true,
)
.unwrap();
for relative in BUNDLE_FILES {
assert_eq!(
fs::read(source.path().join(relative)).unwrap(),
fs::read(destination.path().join(relative)).unwrap()
);
}
assert!(!destination.path().join("bridge.gd").exists());
assert!(!destination.path().join("bin/win-x64/extra.dll").exists());
}
#[test]
fn unsupported_or_disabled_targets_need_no_native_artifacts() {
let destination = tempfile::tempdir().unwrap();
for (target, enabled) in [
("aarch64-apple-darwin", true),
("x86_64-apple-darwin", true),
("x86_64-unknown-linux-gnu", true),
("aarch64-pc-windows-msvc", true),
("x86_64-pc-windows-msvc", false),
] {
stage(
Path::new("missing-godot-native"),
destination.path(),
target,
enabled,
)
.unwrap();
assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
}
}
#[test]
fn incomplete_or_tampered_bundle_fails_before_copying() {
let source = tempfile::tempdir().unwrap();
let destination = tempfile::tempdir().unwrap();
fixture(source.path());
fs::write(source.path().join(BUNDLE_FILES[0]), b"tampered").unwrap();
assert!(stage(
source.path(),
destination.path(),
"x86_64-pc-windows-msvc",
true
)
.unwrap_err()
.contains("SHA256"));
assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
fixture(source.path());
fs::remove_file(source.path().join("vendor/LICENSE.txt")).unwrap();
assert!(validate(source.path()).is_err());
}
#[test]
fn source_watch_list_excludes_build_outputs() {
let source = tempfile::tempdir().unwrap();
fixture(source.path());
fs::create_dir(source.path().join(".build")).unwrap();
fs::write(source.path().join(".build/bridge.obj"), "generated").unwrap();
fs::write(source.path().join("bridge.gd"), "source").unwrap();
let sources = source_files(source.path()).unwrap();
assert_eq!(sources.len(), 3);
assert!(sources.contains(&source.path().join("bridge.gd")));
assert!(!sources.iter().any(|path| path
.components()
.any(|component| component.as_os_str() == "bin" || component.as_os_str() == ".build")));
}
}
@@ -1,132 +0,0 @@
// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs
// 来源:build_support/package-layout.json。不要手工编辑本文件。
// 修改随包资源布局请编辑声明文件,然后运行
// npm run agc:package-layout:sync(在仓库根目录)
// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。
pub const DECLARATION_SCHEMA: &str = "agc-package-layout.v1";
pub const LAYOUT_VERSION: u64 = 1;
pub const CODEX_VERSION: &str = "0.155.1";
pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1";
pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2";
pub const GODOT_BUNDLE_FILES: &[&str] = &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"];
pub const CODEX: Codex = Codex {
package_metadata: PackageMetadata {
layout_version: 1,
resources_dir: "codex-resources",
path_dir: "codex-path",
},
resource_directory: "resources/codex",
manifest_file_name: "manifest.json",
package_metadata_file_name: "codex-package.json",
notice_file_name: "NOTICE.md",
source_roots: &["app", "repo"],
source_relative_paths: &["node_modules/@openai/codex-<platform>/vendor/<target>", "node_modules/@openai/codex/node_modules/@openai/codex-<platform>/vendor/<target>"],
notice_sources: &[
NoticeSource {
targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"],
source: "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md",
preserve: false,
},
NoticeSource {
targets: &["x86_64-pc-windows-msvc"],
source: "resources/codex/win-x64/NOTICE.md",
preserve: true,
}
],
universal_groups: &[
UniversalGroup {
name: "mac-native",
directory: "mac-native",
targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"],
}
],
targets: &[
CodexTarget {
target: "x86_64-pc-windows-msvc",
platform: "win32-x64",
directory: "win-x64",
executable: "bin/codex.exe",
files: &["bin/codex.exe", "bin/codex-code-mode-host.exe", "codex-path/rg.exe", "codex-resources/codex-command-runner.exe", "codex-resources/codex-windows-sandbox-setup.exe", "codex-package.json"],
},
CodexTarget {
target: "aarch64-apple-darwin",
platform: "darwin-arm64",
directory: "mac-native/darwin-arm64",
executable: "bin/codex",
files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"],
},
CodexTarget {
target: "x86_64-apple-darwin",
platform: "darwin-x64",
directory: "mac-native/darwin-x64",
executable: "bin/codex",
files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"],
}
],
};
pub const PLUGINS: Plugins = Plugins {
source_directory: "plugins",
destination_directory: "resources/plugins",
manifest_file_name: "plugin.json",
target_contains_any: &["windows", "apple-darwin"],
subdirectories: &[
Subdirectory {
path: "src",
plugin: "",
origin: "source",
target_contains: &[],
targets: &[],
features: &[],
},
Subdirectory {
path: "panels",
plugin: "",
origin: "source",
target_contains: &[],
targets: &[],
features: &[],
},
Subdirectory {
path: "skills",
plugin: "",
origin: "source",
target_contains: &[],
targets: &[],
features: &[],
},
Subdirectory {
path: "native/payload",
plugin: "agc-cocos-editor",
origin: "prepared",
target_contains: &["windows"],
targets: &[],
features: &["cocos-editor-injection"],
},
Subdirectory {
path: "dotnet/publish/win-x64",
plugin: "agc-unity-editor",
origin: "prepared",
target_contains: &[],
targets: &["x86_64-pc-windows-msvc"],
features: &["unity-editor-execute"],
}
],
library_staging: &[
LibraryStaging {
plugin: "agc-godot-editor",
source_subdirectory: "native/gdextension",
targets: &["x86_64-pc-windows-msvc"],
features: &["godot-editor-execute"],
layout: "godot-bundle",
}
],
skip_directory_names: &["target", "node_modules"],
skip_directory_name_prefixes: &["."],
skip_file_name_prefixes: &["."],
skip_file_name_fragments: &[".test."],
};
@@ -1,258 +0,0 @@
{
"schema": "agc-package-layout.v1",
"layoutVersion": 1,
"description": "AGC 随包资源布局与复制规则的唯一声明。Rust 侧构建期校验与 Node 侧准备步骤共用本文件,任何一侧都不得再写第二份布局或组件白名单。含 <platform>、<target> 占位符的字段由调用方按目标三元展开。修改布局时同步递增 layoutVersion(准备步骤的缓存 key 组成部分)。",
"codex": {
"version": "0.155.1",
"cliVersionPrefix": "codex-cli ",
"manifestSchema": "genarrative-codex-sidecar.v2",
"packageMetadata": {
"layoutVersion": 1,
"resourcesDir": "codex-resources",
"pathDir": "codex-path"
},
"resourceDirectory": "resources/codex",
"manifestFileName": "manifest.json",
"packageMetadataFileName": "codex-package.json",
"noticeFileName": "NOTICE.md",
"sourceRoots": [
"app",
"repo"
],
"sourceRelativePaths": [
"node_modules/@openai/codex-<platform>/vendor/<target>",
"node_modules/@openai/codex/node_modules/@openai/codex-<platform>/vendor/<target>"
],
"noticeSources": [
{
"targets": [
"aarch64-apple-darwin",
"x86_64-apple-darwin"
],
"source": "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md",
"preserve": false
},
{
"targets": [
"x86_64-pc-windows-msvc"
],
"source": "resources/codex/win-x64/NOTICE.md",
"preserve": true
}
],
"universalGroups": [
{
"name": "mac-native",
"directory": "mac-native",
"targets": [
"aarch64-apple-darwin",
"x86_64-apple-darwin"
]
}
],
"targets": [
{
"target": "x86_64-pc-windows-msvc",
"platform": "win32-x64",
"directory": "win-x64",
"executable": "bin/codex.exe",
"files": [
"bin/codex.exe",
"bin/codex-code-mode-host.exe",
"codex-path/rg.exe",
"codex-resources/codex-command-runner.exe",
"codex-resources/codex-windows-sandbox-setup.exe",
"codex-package.json"
]
},
{
"target": "aarch64-apple-darwin",
"platform": "darwin-arm64",
"directory": "mac-native/darwin-arm64",
"executable": "bin/codex",
"files": [
"bin/codex",
"bin/codex-code-mode-host",
"codex-path/rg",
"codex-resources/zsh/bin/zsh",
"codex-package.json"
]
},
{
"target": "x86_64-apple-darwin",
"platform": "darwin-x64",
"directory": "mac-native/darwin-x64",
"executable": "bin/codex",
"files": [
"bin/codex",
"bin/codex-code-mode-host",
"codex-path/rg",
"codex-resources/zsh/bin/zsh",
"codex-package.json"
]
}
]
},
"plugins": {
"sourceDirectory": "plugins",
"destinationDirectory": "resources/plugins",
"manifestFileName": "plugin.json",
"targetContainsAny": [
"windows",
"apple-darwin"
],
"subdirectories": [
{
"path": "src",
"origin": "source"
},
{
"path": "panels",
"origin": "source"
},
{
"path": "skills",
"origin": "source"
},
{
"path": "native/payload",
"origin": "prepared",
"targetContains": [
"windows"
],
"plugin": "agc-cocos-editor",
"prepare": "cocos-bridge-build",
"features": [
"cocos-editor-injection"
]
},
{
"path": "dotnet/publish/win-x64",
"origin": "prepared",
"prepare": "unity-helper-publish",
"targets": [
"x86_64-pc-windows-msvc"
],
"features": [
"unity-editor-execute"
],
"plugin": "agc-unity-editor"
}
],
"libraryStaging": [
{
"plugin": "agc-godot-editor",
"sourceSubdirectory": "native/gdextension",
"prepare": "godot-extension-build",
"targets": [
"x86_64-pc-windows-msvc"
],
"features": [
"godot-editor-execute"
],
"layout": "godot-bundle",
"files": [
"bin/win-x64/agc_godot_editor.dll",
"bin/win-x64/metadata.json",
"vendor/LICENSE.txt",
"vendor/provenance.json"
]
}
],
"nativePayloads": [
{
"plugin": "agc-cocos-editor",
"prepare": "cocos-bridge-build",
"sourceFileName": "cocos_editor_bridge.dll",
"destinationSubdirectory": "native/payload",
"targets": [
"x86_64-pc-windows-msvc"
],
"features": [
"cocos-editor-injection"
],
"destinationFileName": "cocos-editor-bridge.dll"
}
],
"prepareSteps": [
{
"name": "unity-helper-publish",
"kind": "powershell",
"workingDirectory": "plugins/agc-unity-editor/dotnet",
"scriptFileName": "build.ps1",
"fingerprint": {
"roots": [
"."
],
"excludeDirectoryNames": [
"bin",
"obj",
"publish",
"native-build"
],
"stampRelativePath": "publish/win-x64/.agc-source.sha256"
},
"requiredOutputs": [
"plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe",
"plugins/agc-unity-editor/dotnet/publish/win-x64/NOTICE",
"plugins/agc-unity-editor/dotnet/publish/win-x64/THIRD-PARTY-NOTICES.txt",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/DotCraft-Apache-2.0.txt",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/Roslyn-MIT.txt",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/upstream.json",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-LICENSE.TXT",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-THIRD-PARTY-NOTICES.TXT",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf",
"plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf"
]
},
{
"name": "godot-extension-build",
"kind": "powershell",
"workingDirectory": "plugins/agc-godot-editor/native/gdextension",
"scriptFileName": "build.ps1",
"removeEnvironment": [
"PSModulePath"
],
"requiredOutputs": [
"plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll",
"plugins/agc-godot-editor/native/gdextension/bin/win-x64/metadata.json",
"plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt",
"plugins/agc-godot-editor/native/gdextension/vendor/provenance.json"
],
"fingerprint": {
"roots": [
"."
],
"excludeDirectoryNames": [
"bin",
".build",
"native-build"
],
"stampRelativePath": "bin/win-x64/.agc-source.sha256"
}
},
{
"name": "cocos-bridge-build",
"kind": "cargo",
"packageDirectory": "plugins/agc-cocos-editor/native/cocos-editor-bridge",
"features": [
"windows-injection"
],
"requiredOutputs": []
}
],
"skipDirectoryNames": [
"target",
"node_modules"
],
"skipDirectoryNamePrefixes": [
"."
],
"skipFileNamePrefixes": [
"."
],
"skipFileNameFragments": [
".test."
]
}
}
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,11 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
#[path = "../../build_support/codex_bundle.rs"]
pub(crate) mod codex_bundle;
// 复用构建端校验的既有单测,生产运行时只编译共享布局。
#[cfg(test)]
#[path = "../../build_support/codex_package_metadata.rs"]
mod codex_package_metadata;
const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex";
const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024;
const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024;
@@ -2326,6 +2326,30 @@ fn direct_taonier_reference_matches_local_source(
.unwrap_or(false)
}
fn direct_taonier_background_asset_identity(
root: &Path,
expected_reference_source: Option<&DirectTaonierArtAssetIdentity>,
) -> Option<DirectTaonierArtAssetIdentity> {
direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/scenes/generations",
"scene",
expected_reference_source,
)
.or_else(|| {
direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
expected_reference_source,
)
})
}
fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
let Some(art_spec) = direct_taonier_art_asset_identity(
root,
@@ -2337,14 +2361,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
) else {
return false;
};
let Some(background) = direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(&art_spec),
) else {
let Some(background) = direct_taonier_background_asset_identity(root, Some(&art_spec)) else {
return false;
};
let _ = (background, art_spec);
@@ -3127,27 +3144,16 @@ async fn generate_direct_taonier_art_asset_at(
};
let runtime_context =
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
let generation = if retain_runtime_state {
generate_platform_art_asset_with_retained_runtime_options_at(
root,
prompt,
&[],
&options,
require_slices,
&runtime_context,
)
.await
} else {
generate_platform_art_asset_with_runtime_options_at(
root,
prompt,
&[],
&options,
require_slices,
&runtime_context,
)
.await
};
let generation = generate_art_package_asset_with_runtime_options_at(
root,
prompt,
&[],
&options,
require_slices,
retain_runtime_state,
&runtime_context,
)
.await;
let generated = match generation {
Ok(generated) => generated,
Err(error)
@@ -3325,15 +3331,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
})
.flatten();
let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| {
direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(art_spec),
)
.is_some()
direct_taonier_background_asset_identity(root, Some(art_spec)).is_some()
});
let art_spec = match existing_art_spec {
Some(identity) => identity,
@@ -3409,15 +3407,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
}
};
if mode.regenerates_existing()
|| direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(&art_spec),
)
.is_none()
|| direct_taonier_background_asset_identity(root, Some(&art_spec)).is_none()
{
emit_direct_game_creator_progress(root, "art.background", "正在生成 16:9 游戏场景背景图");
let outcome = match generate_direct_taonier_art_asset_at(
@@ -8294,8 +8284,8 @@ mod tests {
vec!["taonier-resource-icon-spec".to_string()],
),
GameCreationAppAssetKind::Scene => (
"/api/external/v1/editor/images/generations",
"spec",
"/api/external/v1/editor/scenes/generations",
"scene",
vec!["taonier-resource-icon-spec".to_string()],
),
_ => (
@@ -8679,6 +8669,46 @@ mod tests {
);
}
#[test]
fn legacy_route_background_registration_still_counts_as_valid_base() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "legacy-background-route", "旧路由背景登记")
.expect("init project");
std::fs::create_dir_all(root.path().join("assets")).expect("assets dir");
register_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
GameCreationAppAssetKind::IconSpec,
);
std::fs::write(
root.path().join(DIRECT_CODEX_BACKGROUND_ASSET_PATH),
tiny_visible_png(),
)
.expect("background bytes");
register_local_asset_at(
root.path(),
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"image/png",
"platform-art",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("taonier-project".to_string()),
resource_id: Some("taonier-resource-game-background".to_string()),
asset_object_id: Some("taonier-object-game-background".to_string()),
task_id: Some("taonier-task-game-background".to_string()),
prompt: None,
model: Some("gpt-image-2".to_string()),
generation_route: Some("/api/external/v1/editor/images/generations".to_string()),
generation_kind: Some("spec".to_string()),
reference_resource_ids: vec!["taonier-resource-icon-spec".to_string()],
},
)
.expect("register legacy-route background");
assert!(direct_taonier_art_base_is_valid(root.path()));
}
fn tiny_visible_png() -> Vec<u8> {
let mut bytes = Vec::new();
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
@@ -18,8 +18,7 @@ pub(crate) use canvas_generation::submit_external_generation_request;
pub(in crate::agent) use canvas_generation::{
admit_platform_art_generation_at, commit_prepared_platform_art_asset_at,
commit_prepared_platform_art_asset_strict_slices_at, generate_admitted_platform_art_asset_at,
generate_platform_art_asset_with_retained_runtime_options_at,
generate_platform_art_asset_with_runtime_options_at,
generate_art_package_asset_with_runtime_options_at,
platform_art_generation_error_result_unknown, recover_persisted_visual_generation_options,
register_existing_platform_art_slices_at, request_platform_art_asset_with_runtime_options_at,
restore_platform_art_asset_bytes_at,
@@ -2267,6 +2267,35 @@ fn decode_platform_art_image_bytes_with_limits(
.map_err(|error| format!("{label}无法在安全内存边界内解码:{error}"))
}
fn normalize_art_package_png(download: &mut CanvasResourceDownload) -> Result<(), String> {
if download.bytes.len() > PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES {
return Err("美术包图片超过 20 MiB 本地校验上限".to_string());
}
let format = image::guess_format(&download.bytes)
.map_err(|error| format!("美术包图片无法识别格式:{error}"))?;
if !matches!(
format,
image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::WebP
) {
return Err("美术包图片仅支持 PNG、JPEG、WebP".to_string());
}
// PNG 也必须完整解码;保留原字节以兼容既有摘要和恢复记录。
let decoded = decode_platform_art_image_with_limits(download, "美术包图片")?;
if format != image::ImageFormat::Png {
let mut output = std::io::Cursor::new(Vec::new());
decoded
.write_to(&mut output, image::ImageFormat::Png)
.map_err(|error| format!("美术包图片转为 PNG 失败:{error}"))?;
let bytes = output.into_inner();
if bytes.len() > PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES {
return Err("美术包图片转为 PNG 后超过 20 MiB 本地校验上限".to_string());
}
download.bytes = bytes;
}
download.media_type = "image/png".to_string();
Ok(())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(in crate::agent) struct ValidatedPlatformArtPng {
pub(in crate::agent) width: u32,
@@ -2555,6 +2584,7 @@ pub(in crate::agent) async fn generate_admitted_platform_art_asset_at(
false,
&context,
Some(admission.guard),
false,
)
.await
}
@@ -2735,20 +2765,19 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_runtime_options_a
false,
runtime_context,
None,
false,
)
.await
}
/// Installs a recovered or newly generated asset while retaining the durable
/// request state. Package-level workflows use this to compensate local files
/// after a later stage fails without losing the accepted operation identity or
/// issuing another billable POST when the package is retried.
pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_options_at(
/// 美术包统一输出 PNG;重生成时保留账本,以便后续阶段失败后复用已付费结果。
pub(in crate::agent) async fn generate_art_package_asset_with_runtime_options_at(
root: &Path,
prompt: &str,
briefs: &[AgentGroupBrief],
options: &PlatformArtAssetGenerationOptions,
require_slices: bool,
retain_runtime_state: bool,
runtime_context: &PlatformArtGenerationRuntimeContext,
) -> Result<GeneratedPlatformArtAsset, String> {
generate_platform_art_asset_with_runtime_options_and_retention_at(
@@ -2757,9 +2786,10 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_
briefs,
options,
require_slices,
true,
retain_runtime_state,
runtime_context,
None,
true,
)
.await
}
@@ -2835,14 +2865,15 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d
&& art_spec_asset_type == Some("icon-spec")
}
GameCreationAppAssetKind::Scene => {
snapshot.endpoint == "/api/external/v1/editor/images/generations"
&& snapshot.generation_kind == "spec"
snapshot.endpoint == "/api/external/v1/editor/scenes/generations"
&& snapshot.generation_kind == "scene"
&& platform_art_runtime_references_match_request_contract(
&snapshot.reference_resource_ids,
GameCreationAppAssetKind::Scene,
)
&& request_asset_kind.as_deref() == Some("game-background")
&& art_spec_asset_type == Some("background")
&& json_string_field(&request_body, "sceneContent")
.is_some_and(|value| !value.trim().is_empty())
&& json_string_field(&request_body, "stylePreset").as_deref() == Some("custom")
}
GameCreationAppAssetKind::IconSpritesheet => {
snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations"
@@ -2908,6 +2939,7 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at(
retain_runtime_state: bool,
runtime_context: &PlatformArtGenerationRuntimeContext,
admitted_guard: Option<super::external_generation_state::DurablePlatformArtGenerationGuard>,
normalize_package_png: bool,
) -> Result<GeneratedPlatformArtAsset, String> {
if require_slices && options.asset_kind != GameCreationAppAssetKind::IconSpritesheet {
return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string());
@@ -2979,7 +3011,7 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at(
)?;
recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?;
}
let prepared = request_platform_art_asset_with_runtime_options_at(
let mut prepared = request_platform_art_asset_with_runtime_options_at(
root,
prompt,
briefs,
@@ -2987,6 +3019,21 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at(
Some(runtime_context),
)
.await?;
if normalize_package_png {
normalize_art_package_png(&mut prepared.download)?;
prepared.extension = "png".to_string();
if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet
&& prepared.download.bytes.len()
+ prepared
.slices
.iter()
.map(|slice| slice.download.bytes.len())
.sum::<usize>()
> PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES
{
return Err("美术包图集转码后与切片累计超过 32 MiB,已拒绝同步".to_string());
}
}
prepared.validate_frozen_session()?;
let lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
@@ -3248,6 +3295,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
GameCreationAppAssetKind::Image => "image",
GameCreationAppAssetKind::Character => "character",
GameCreationAppAssetKind::PublicationMaterial => "publication-material",
GameCreationAppAssetKind::Scene => "scene",
_ => "spec",
};
let is_canonical_art_spritesheet =
@@ -3291,6 +3339,25 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
},
}),
)
} else if options.asset_kind == GameCreationAppAssetKind::Scene {
(
"/api/external/v1/editor/scenes/generations",
serde_json::json!({
"sceneContent": generation_prompt,
"stylePreset": "custom",
"customStyle": prompt_text!("media.scene_style"),
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
"assetLabel": options.asset_label,
"projectId": canvas_context.project_id,
"assetFolderId": canvas_context.asset_folder_id,
"referenceImageSrcs": references.ordered.clone(),
"canvasCompletion": {
"title": options.asset_label,
"placeholder": external_canvas_placeholder(&options.aspect_ratio),
},
}),
)
} else {
(
"/api/external/v1/editor/images/generations",
@@ -10301,6 +10368,81 @@ mod canvas_generation_tests {
rgba_test_png_with_quality(alpha, CompressionType::Fast, FilterType::Adaptive)
}
fn art_package_test_image(format: image::ImageFormat) -> CanvasResourceDownload {
let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
2,
2,
image::Rgb([12, 34, 56]),
));
let mut output = std::io::Cursor::new(Vec::new());
image.write_to(&mut output, format).expect("encode fixture");
CanvasResourceDownload {
bytes: output.into_inner(),
media_type: format.to_mime_type().to_string(),
}
}
#[test]
fn art_package_png_normalization_preserves_png_bytes_and_real_alpha() {
let mut png = rgba_test_png(120);
let original = png.bytes.clone();
png.media_type = "application/octet-stream".to_string();
normalize_art_package_png(&mut png).expect("validate existing PNG");
assert_eq!(png.bytes, original);
assert_eq!(png.media_type, "image/png");
let mut webp = std::io::Cursor::new(Vec::new());
image::load_from_memory(&original)
.unwrap()
.write_to(&mut webp, image::ImageFormat::WebP)
.unwrap();
let mut webp = CanvasResourceDownload {
bytes: webp.into_inner(),
media_type: "image/webp".to_string(),
};
normalize_art_package_png(&mut webp).unwrap();
let validated = validate_platform_art_png_bytes_with_limits(&webp.bytes, "test").unwrap();
assert!(validated.has_transparent_pixels && validated.has_visible_pixels);
assert_eq!(
validated.pixel_sha256,
validate_platform_art_png_bytes_with_limits(&original, "test")
.unwrap()
.pixel_sha256
);
let mut jpeg = art_package_test_image(image::ImageFormat::Jpeg);
normalize_art_package_png(&mut jpeg).unwrap();
let (transparent, visible, _) = platform_art_spritesheet_alpha_contract(&jpeg).unwrap();
assert!(!transparent, "JPEG 转码不能补造透明像素");
assert!(visible);
}
#[test]
fn art_package_png_normalization_rejects_invalid_or_oversized_images() {
let mut oversized = Vec::new();
PngEncoder::new(&mut oversized)
.write_image(
&vec![0; (PLATFORM_ART_SPRITESHEET_MAX_DIMENSION as usize + 1) * 4],
PLATFORM_ART_SPRITESHEET_MAX_DIMENSION + 1,
1,
ColorType::Rgba8.into(),
)
.unwrap();
for bytes in [
b"GIF89a".to_vec(),
b"\x89PNG\r\n\x1a\n".to_vec(),
vec![0xff, 0xd8, 0xff],
oversized,
vec![0; PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES + 1],
] {
let mut download = CanvasResourceDownload {
bytes,
media_type: "image/png".to_string(),
};
assert!(normalize_art_package_png(&mut download).is_err());
}
}
fn rgba_test_png_with_quality(
alpha: u8,
compression: CompressionType,
@@ -10986,6 +11128,16 @@ mod canvas_generation_tests {
#[tokio::test]
async fn retained_runtime_generation_retries_a_completed_stage_without_posting_again() {
for format in [
image::ImageFormat::Png,
image::ImageFormat::Jpeg,
image::ImageFormat::WebP,
] {
assert_art_package_recovers_completed_stage(format).await;
}
}
async fn assert_art_package_recovers_completed_stage(format: image::ImageFormat) {
let temporary = tempfile::tempdir().expect("create retained runtime project");
let root = temporary.path();
init_local_game_project_at(root, "retained-runtime", "整包阶段恢复")
@@ -10993,12 +11145,14 @@ mod canvas_generation_tests {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind retained download fixture");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let image_url = format!("{base_url}/retained.png");
let extension = format.extensions_str()[0];
let image_url = format!("{base_url}/retained.{extension}");
let signed_image_url = image_url.clone();
let png = rgba_test_png(u8::MAX).bytes;
let source = art_package_test_image(format);
let source_bytes = source.bytes.clone();
let (request_sender, request_receiver) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
for _ in 0..4 {
for request_index in 0..6 {
let (mut stream, _) = listener.accept().expect("accept retained download");
let request = read_test_http_request(&mut stream);
request_sender
@@ -11017,12 +11171,19 @@ mod canvas_generation_tests {
)
.expect("write retained signed URL");
} else {
// 首次下载有合法签名但内容损坏,重试必须仍使用同一已完成账本。
let bytes = if request_index == 1 {
&source.bytes[..16]
} else {
&source.bytes
};
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
png.len()
"HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
source.media_type,
bytes.len()
)
.and_then(|_| stream.write_all(&png))
.and_then(|_| stream.write_all(bytes))
.expect("write retained image");
}
}
@@ -11081,7 +11242,7 @@ mod canvas_generation_tests {
"resource": {
"resourceId": "retained-resource",
"projectId": "retained-canvas-project",
"objectKey": "retained/spec.png",
"objectKey": format!("retained/spec.{extension}"),
"imageSrc": image_url
},
"taskId": "retained-task",
@@ -11097,21 +11258,76 @@ mod canvas_generation_tests {
"retained-runtime-key".to_string(),
),
async {
for _ in 0..2 {
generate_platform_art_asset_with_retained_runtime_options_at(
let ledger_before =
read_platform_art_generation_runtime_state(root, &runtime_context)
.unwrap()
.unwrap();
let failure = generate_art_package_asset_with_runtime_options_at(
root,
prompt,
&[],
&options,
false,
false,
&runtime_context,
)
.await;
assert!(failure.is_err());
assert!(!root.join("assets/art-spec.png").exists());
assert!(read_manifest_for_project(root).unwrap().assets.is_empty());
let ledger_after =
read_platform_art_generation_runtime_state(root, &runtime_context)
.unwrap()
.unwrap();
assert_eq!(
platform_art_generation_runtime_idempotency_key(&ledger_before),
platform_art_generation_runtime_idempotency_key(&ledger_after)
);
assert_eq!(
platform_art_generation_runtime_retained_result(&ledger_before),
platform_art_generation_runtime_retained_result(&ledger_after)
);
let mut installed_bytes = None;
let mut installed_asset_id = None;
for retain in [true, false] {
generate_art_package_asset_with_runtime_options_at(
root,
prompt,
&[],
&options,
false,
retain,
&runtime_context,
)
.await
.expect("recover retained completed stage");
assert!(
let bytes = fs::read(root.join("assets/art-spec.png")).unwrap();
let validated =
validate_platform_art_png_bytes_with_limits(&bytes, "recovered").unwrap();
assert_eq!((validated.width, validated.height), (2, 2));
if format == image::ImageFormat::Png {
assert_eq!(bytes, source_bytes);
}
if let Some(previous) = installed_bytes.replace(bytes) {
assert_eq!(installed_bytes.as_ref().unwrap(), &previous);
}
let manifest = read_manifest_for_project(root).unwrap();
assert_eq!(manifest.assets.len(), 1);
let asset = &manifest.assets[0];
assert_eq!(asset.local_path, "assets/art-spec.png");
assert_eq!(asset.media_type, "image/png");
assert_eq!(
asset.source.resource_id.as_deref(),
Some("retained-resource")
);
if let Some(previous) = installed_asset_id.replace(asset.id.clone()) {
assert_eq!(asset.id, previous);
}
assert_eq!(
read_platform_art_generation_runtime_state(root, &runtime_context)
.expect("read retained stage ledger")
.is_some()
.is_some(),
retain
);
}
},
@@ -11124,13 +11340,13 @@ mod canvas_generation_tests {
.ok()
})
.collect::<Vec<_>>();
assert_eq!(requests.len(), 4);
assert_eq!(requests.len(), 6);
assert_eq!(
requests
.iter()
.filter(|request| request.starts_with("GET /retained.png "))
.filter(|request| request.starts_with(&format!("GET /retained.{extension} ")))
.count(),
2
3
);
assert!(requests.iter().all(|request| request.starts_with("GET ")));
assert!(requests.iter().all(|request| !request.starts_with("POST ")));
@@ -13272,14 +13488,13 @@ mod canvas_generation_tests {
let background = write_retained_stage(
root,
"run-background-canonical-and-user-references",
"/api/external/v1/editor/images/generations",
"/api/external/v1/editor/scenes/generations",
serde_json::json!({
"prompt": "保留账本参考合同",
"kind": "spec",
"assetKind": "game-background",
"sceneContent": "保留账本参考合同",
"stylePreset": "custom",
"customStyle": "原创横屏 Web 游戏场景背景",
"projectId": "test-canvas-project",
"assetFolderId": "test-asset-folder",
"generationInputs": { "artSpec": { "assetType": "background" } },
"referenceImageSrcs": ["resource-icon-spec", "user-reference-1"],
}),
);
@@ -735,6 +735,30 @@ pub(super) fn platform_art_generation_runtime_request_snapshot(
.collect::<Result<Vec<_>, _>>()?;
(generation_kind, reference_resource_ids)
}
"/api/external/v1/editor/scenes/generations" => {
if json_string_field(&request_body, "sceneContent")
.is_none_or(|value| value.trim().is_empty())
{
return Err("External Editor 场景生成账本请求缺少 sceneContent".to_string());
}
let reference_resource_ids = request_body
.get("referenceImageSrcs")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
"External Editor 场景生成账本请求缺少 referenceImageSrcs".to_string()
})?
.iter()
.map(|value| {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| "External Editor 场景生成账本引用资源 ID 无效".to_string())
})
.collect::<Result<Vec<_>, _>>()?;
("scene".to_string(), reference_resource_ids)
}
"/api/external/v1/editor/icon-spritesheets/generations" => {
let reference_resource_id = json_string_field(&request_body, "referenceId")
.or_else(|| json_string_field(&request_body, "referenceImageSrc"))
@@ -555,6 +555,7 @@ fn platform_art_generation_request_snapshot_is_valid(payload: &serde_json::Value
endpoint,
Some(
"/api/external/v1/editor/images/generations"
| "/api/external/v1/editor/scenes/generations"
| "/api/external/v1/editor/icon-spritesheets/generations"
)
);

Some files were not shown because too many files have changed in this diff Show More