Switch to VectorEngine gpt-image-2 and edits

Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
2026-05-22 03:06:41 +08:00
parent 321e1ea33a
commit ae014ac881
90 changed files with 7078 additions and 3389 deletions
+5 -13
View File
@@ -5,7 +5,7 @@ description: Generate or inspect project image assets through this repository's
# gpt-image-2 VectorEngine
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine `gpt-image-2-all` path. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine `gpt-image-2` path. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
## Workflow
@@ -40,22 +40,14 @@ Default body:
```json
{
"model": "gpt-image-2-all",
"model": "gpt-image-2",
"prompt": "<prompt>",
"n": 1,
"size": "1024x1024"
}
```
For weak visual references in text-to-image generation, add:
```json
{
"image": ["data:image/png;base64,..."]
}
```
For image-to-image work that must follow a reference image closely, use the VectorEngine edits endpoint instead of the generations `image` array:
For visual references, use the edit endpoint instead of the create endpoint:
```text
POST {VECTOR_ENGINE_BASE_URL}/v1/images/edits
@@ -73,9 +65,9 @@ size=1024x1024
image=@reference.png
```
Prefer edits for workflows where the reference image controls composition, pose, container shape, or layout. In this repository, Match3D container UI generation uses edits with `public/match3d-background-references/pot-fused-reference.png` as the `image` part.
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine GPT-image-2-all currently returns synchronously; do not poll APIMart task endpoints.
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine GPT-image-2 currently returns synchronously; do not poll APIMart task endpoints.
## Environment
@@ -245,7 +245,7 @@ async function downloadUrl(url, timeoutMs) {
async function generateOne(env, entry, outDir) {
const requestBody = {
model: 'gpt-image-2-all',
model: 'gpt-image-2',
prompt: buildPrompt(entry),
n: 1,
size: '1024x1024',
@@ -305,7 +305,7 @@ if (dryRun) {
id: entry.id,
title: entry.title,
body: {
model: 'gpt-image-2-all',
model: 'gpt-image-2',
prompt: buildPrompt(entry),
n: 1,
size: '1024x1024',
@@ -211,7 +211,7 @@ async function downloadUrl(url, timeoutMs) {
async function generateOne(env, template, outDir) {
const requestBody = {
model: 'gpt-image-2-all',
model: 'gpt-image-2',
prompt: buildPrompt(template),
n: 1,
size: '1024x1024',
@@ -275,7 +275,7 @@ if (dryRun) {
id: template.id,
title: template.title,
body: {
model: 'gpt-image-2-all',
model: 'gpt-image-2',
prompt: buildPrompt(template),
n: 1,
size: '1024x1024',
+28 -9
View File
@@ -19,7 +19,7 @@
## 2026-05-21 外部 API 失败必须 OTLP 上报并落库
- 背景:图片生成等外部供应商调用失败时,仅返回 502/504 或普通日志无法支持后续按 provider、阶段和重试属性聚合排障。
- 决策:外部 API 调用未成功时,`api-server` 必须同时发送 OTLP 失败观测并写入 `tracking_event`。当前通用 VectorEngine `gpt-image-2-all` 图片生成 / 编辑适配器记录 `external_api_call_failure``scope_kind = module``scope_id = provider``module_key = external-api`metadata 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。
- 决策:外部 API 调用未成功时,`api-server` 必须同时发送 OTLP 失败观测并写入 `tracking_event`。当前通用 VectorEngine `gpt-image-2` 图片生成 / 编辑适配器记录 `external_api_call_failure``scope_kind = module``scope_id = provider``module_key = external-api`metadata 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。
- 落库方式:优先复用 tracking outbox 异步批量写入;outbox 不可写或因保护阈值拒绝时回退同步直写 SpacetimeDB。不新增 SpacetimeDB 表,不让 reducer 做外部 I/O。
- 影响范围:`server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/openai_image_generation.rs``server-rs/crates/api-server/src/telemetry.rs`、tracking outbox、后端架构文档和开发运维文档。
- 验证方式:执行 `cargo test -p api-server external_api_audit --manifest-path server-rs/Cargo.toml -- --nocapture``cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding`
@@ -42,6 +42,15 @@
- 验证方式:目标机 `nginx -T 2>/dev/null | grep client_max_body_size` 应看到 `client_max_body_size 64m;`;大于 1 MiB 的参考图请求不再在 Nginx 层直接 413access log 应出现有效 `upstream_status`
- 关联文档:`deploy/nginx/README.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-05-22 抓大鹅素材生成改为关卡整图派生三图
- 背景:旧抓大鹅素材链路按物品 5x5 sheet、纯背景和独立容器图分开生产,难以保证背景、UI、容器和物品风格一致,也让结果页继续暴露背景 / 容器重生成入口。
- 决策:抓大鹅草稿生成先用 `gpt-image-2` 无参考图生成竖屏 `9:16` 完整关卡画面;关卡画面完成后,以它作为参考并发生成三张可运行资产:`1K 1:1` UI spritesheet、`1K 9:16` 关卡背景图、`2K 1:1` 物品 spritesheet。UI 与物品 spritesheet 都固定要求纯绿色绿幕背景,后端上传 OSS 前扣成真实透明 PNG。物品 spritesheet 固定 `10*10`,每行两种物品、每种五个形态。运行态和编辑器都按 alpha 连通域矩形检测解析 UI 和物品图集,不按固定像素坐标切图。
- 兼容:新增字段继续存入现有 `generatedItemAssets[].backgroundAsset` / `generatedBackgroundAsset` JSON,不新增 SpacetimeDB schema 字段。历史 `containerImage*` 字段只作兼容;如果它与 `uiSpritesheetImage*` 同源,不得再作为运行态中心容器图。
- 影响范围:`server-rs/crates/api-server/src/match3d/*``server-rs/crates/shared-contracts/src/match3d_*``packages/shared/src/contracts/match3dWorks.ts``src/components/match3d-result/Match3DResultView.tsx``src/components/match3d-runtime/Match3DRuntimeShell.tsx``src/services/match3dSpritesheetParser.ts`
- 验证方式:执行 `cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml``npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/services/match3dSpritesheetParser.test.ts src/services/match3dGeneratedModelCache.test.ts``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-18 Rust 手写模块入口统一不用 mod.rs
@@ -223,6 +232,7 @@
## 2026-05-14 抓大鹅物品素材 sheet 改用 VectorEngine Gemini
- 状态:历史决策,已被 `2026-05-22 抓大鹅素材生成改为关卡整图派生三图` 取代;当前物品 spritesheet 走 `gpt-image-2` 参考关卡整图编辑生成 `2K 1:1``10*10` 绿幕图,上传 OSS 前扣成透明 PNG。
- 背景:抓大鹅 2D 五视角物品素材仍沿用 5x5 sheet、绿幕去背、切图、OSS 转存和 `generatedItemAssets` 持久化,但用户要求物品素材图片生成步骤改用 VectorEngine Apifox `api-381740608` 对应的 Gemini 原生图片接口。
- 决策:抓大鹅物品素材 sheet 生图固定走 VectorEngine `POST {VECTOR_ENGINE_BASE_URL}/v1beta/models/gemini-3-pro-image-preview:generateContent?key={VECTOR_ENGINE_API_KEY}`,请求体使用 `contents[].parts[].text``generationConfig.responseModalities = ["TEXT", "IMAGE"]``imageConfig.aspectRatio = "1:1"`;响应从 `candidates[].content.parts[].inlineData.data` / `inline_data.data` 读取 base64 图片。封面、9:16 纯背景图、1:1 容器 UI 图、切图、OSS、扣费和运行态消费链路保持不变;音频以后续“拼图与抓大鹅音频生成入口临时关闭”决策为准。
- 影响范围:`server-rs/crates/api-server/src/match3d.rs``server-rs/crates/api-server/src/config.rs``deploy/env/api-server.env.example`、抓大鹅素材生成技术文档。
@@ -274,7 +284,7 @@
## 2026-05-12 抓大鹅入口素材风格改为 2D 常见素材风格
- 背景:抓大鹅草稿素材生成已经收敛为多视角 2D 图片素材,但入口页和旧参考图仍沿用黏土、低多边形、塑料、木雕、体素、金属等偏 3D 素材语言,容易让后续生成链路和用户预期继续漂移。
- 决策:抓大鹅创作入口 `2D素材风格` 固定为 `扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义`;默认风格为 `flat-icon`。入口参考图统一由 `npm run assets:match3d-style-references -- --live` 调用 VectorEngine `gpt-image-2-all` 生成,输出到 `public/match3d-style-references/`。旧 3D 风格参考图不再保留为入口资产。
- 决策:抓大鹅创作入口 `2D素材风格` 固定为 `扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义`;默认风格为 `flat-icon`。入口参考图统一由 `npm run assets:match3d-style-references -- --live` 调用 VectorEngine `gpt-image-2` 生成,输出到 `public/match3d-style-references/`。旧 3D 风格参考图不再保留为入口资产。
- 影响范围:`Match3DAgentWorkspace`、抓大鹅入口交互测试、Match3D PRD、素材生成流水线技术文档、F1 入口文档和 `public/match3d-style-references/` 静态资产。
- 验证方式:执行 `npm run test -- src\components\match3d-creation\Match3DAgentWorkspace.interaction.test.tsx``cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml``npm run typecheck``npm run check:encoding`,并人工抽查 `.tmp/match3d-style-preview.png`
- 关联文档:`docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md``docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md`
@@ -290,8 +300,9 @@
## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化
- 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。
- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt``uiBackgroundImageSrc``uiBackgroundImageObjectKey``compile_puzzle_draft` 草稿编译阶段自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc``uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。
- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt``uiBackgroundImageSrc``uiBackgroundImageObjectKey``compile_puzzle_draft` 草稿编译阶段自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc``uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。
- 2026-05-18 追加:为缩短首版草稿等待,`compile_puzzle_draft` 在首关命名和 `uiBackgroundPrompt` 稳定后并行启动首关关卡图生成与 UI 背景生成;上传主图且关闭 AI 重绘时,并行执行上传图持久化与 UI 背景生成。生成页预计完成时间按 5 分钟展示。
- 2026-05-21 追加:拼图结果页独立“素材配置”Tab 已移除,UI spritesheet 与关卡纯背景收口到每关图片生成资产包。每次 `gpt-image-2` 预计 90 秒;草稿完整 AI 重绘路径约 298 秒,上传图且关闭 AI 重绘路径跳过首图生成约 208 秒。结果页关卡详情继续复用 `CreativeImageInputPanel`,本次上传/历史选择图优先成为主图卡片,正式图只作为无新参考图时的预览;仅有正式图时仍允许在画面描述框上传多张参考图。
- 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。
- 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx``cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`
@@ -299,7 +310,7 @@
## 2026-05-12 抓大鹅结果页素材编辑统一走作品级资产面板
- 背景:抓大鹅结果页需要支持封面图上传 / AI 重绘、物品素材独立预览、单项删除和批量新增,且不能把素材编辑继续做成列表内联展开或前端临时状态。
- 决策:结果页 `作品信息` 的封面图点击打开独立面板,封面图面板对齐拼图入口上传卡。已有上传主图时,请求体传 `uploadedImageSrc`AI 重绘走 VectorEngine `/v1/images/edits`;关闭 AI 重绘时只写回上传图,不调用生图。没有上传主图时,请求体传 `referenceImageSrcs`,可混合本地上传、物品素材和 UI 素材,多参考图作为 `gpt-image-2-all` generations 的 `image` 数组传入。生成结果统一调用 `POST /api/creation/match3d/works/{profileId}/cover-image` 并转存到 `generated-match3d-assets``素材配置 > 物品` 列表项点击打开独立预览面板,不再提供单项重新生成按钮;单项删除和批量新增都写回同一份 `generated_item_assets_json`。批量新增调用 `POST /api/creation/match3d/works/{profileId}/item-assets`,复用草稿生成的 2D 素材图、5x5 切图、OSS 上传和可选点击音效链路,仅作用于新增物品,不新增 SpacetimeDB 表
- 决策:结果页 `作品信息` 的封面图点击打开独立面板,封面图面板对齐拼图入口上传卡。已有上传主图时,请求体传 `uploadedImageSrc`AI 重绘走 VectorEngine `/v1/images/edits`,后端把上传图作为 multipart `image` part 传入 `gpt-image-2`;关闭 AI 重绘时只写回上传图,不调用生图。没有上传主图但存在 `referenceImageSrcs` 时,多参考图同样走 edits 的多个 `image` part;完全无参考图时走 `/v1/images/generations`。生成结果统一调用 `POST /api/creation/match3d/works/{profileId}/cover-image` 并转存到 `generated-match3d-assets``素材配置 > 物品` 列表项点击打开独立预览面板,不再提供单项重新生成按钮;单项删除和批量新增都写回同一份 `generated_item_assets_json`。批量新增调用 `POST /api/creation/match3d/works/{profileId}/item-assets`;该接口的物品 spritesheet 生成口径已被 2026-05-22 决策更新为关卡整图参考、`10*10` 绿幕图和上传前透明化
- 影响范围:Match3D 结果页、Match3D works shared contracts、`api-server` Match3D 作品路由、生成资产历史类型和草稿恢复路径。
- 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``npm run typecheck``cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding`
- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
@@ -322,7 +333,7 @@
## 2026-05-13 宝贝爱画先作为寓教于乐独立本地 Demo 落地
- 背景:第三关 `宝贝爱画` 需要默认出现在“发现 / 寓教于乐”板块下方,但本阶段只验证画板、手部绘制、绘画魔法和本地保存闭环,不进入创作模板、公开作品或正式持久化。
- 决策:`baby-love-drawing / 宝贝爱画` 先作为独立运行态接入,入口由发现页寓教于乐默认卡片打开,并支持 `/runtime/baby-love-drawing` 直达;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时前端不展示频道/卡片且直达路由回落主应用。绘画魔法统一走 `POST /api/creation/edutainment/baby-love-drawing/magic` 后端安全代理,使用 VectorEngine `gpt-image-2-all` 与原始画布 Data URL 参考图生成绘本风图片;保存只写 localStorage,正式持久化后续再设计。
- 决策:`baby-love-drawing / 宝贝爱画` 先作为独立运行态接入,入口由发现页寓教于乐默认卡片打开,并支持 `/runtime/baby-love-drawing` 直达;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时前端不展示频道/卡片且直达路由回落主应用。绘画魔法统一走 `POST /api/creation/edutainment/baby-love-drawing/magic` 后端安全代理,使用 VectorEngine `gpt-image-2` 与原始画布 Data URL 参考图生成绘本风图片;保存只写 localStorage,正式持久化后续再设计。
- 影响范围:`packages/shared/src/contracts/edutainmentBabyDrawing.ts``src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.tsx``src/services/edutainment-baby-drawing/``src/routing/appRoutes.tsx``src/components/rpg-entry/RpgEntryHomeView.tsx``server-rs/crates/api-server/src/edutainment_baby_drawing.rs``src/index.css`、宝贝爱画 PRD 与技术方案。
- 验证方式:执行宝贝爱画 model/runtime/service/route 定向测试、`npm run typecheck`、定向 ESLint、`cargo test -p api-server edutainment_baby_drawing --manifest-path server-rs/Cargo.toml``cargo test -p api-server resolves_runtime_paths_to_creation_type_ids --manifest-path server-rs/Cargo.toml` 和编码检查;真实魔法生成需配置 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY`
- 关联文档:`docs/prd/BABY_LOVE_DRAWING_EDUTAINMENT_LEVEL_PRD_2026-05-13.md``docs/technical/BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md`
@@ -355,7 +366,7 @@
## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台
- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径。
- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色指示器和 UI 已拆分为用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v3.png``picture-book-character-outline-v4.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`。其中角色指示器 v4 基于 v2 本地后处理为更细的白色描边样式,内部透明,耳朵、手指、脚趾等细节已弱化,页面显示尺寸相对上一版放大 50%。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。
- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色指示器和 UI 已拆分为用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v3.png``picture-book-character-outline-v4.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`。其中角色指示器 v4 基于 v2 本地后处理为更细的白色描边样式,内部透明,耳朵、手指、脚趾等细节已弱化,页面显示尺寸相对上一版放大 50%。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。
- 影响范围:`src/index.css``src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。
- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live``--live --only <asset-id>` 应能写出对应 PNG,并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 HUD、状态条或底部草坪。
- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md``docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`
@@ -403,11 +414,19 @@
## 2026-05-09 GPT-image-2 图片生成统一迁移到 VectorEngine
- 背景:仓库内 RPG、拼图、方洞和本地模板脚本的 GPT-image-2 生图此前依赖 APIMart 图片网关;团队要求参考 VectorEngine Apifox `api-448710071`,后续不再使用 APIMart 执行 GPT-image-2 图片生成。
- 决策:所有 GPT-image-2 生图请求统一走 VectorEngine `POST /v1/images/generations`,基础配置读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` / `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,上游模型使用 `gpt-image-2-all`,请求体不再携带 `official_fallback`,参考图字段改为 `image`。APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。
- 决策:所有 GPT-image-2 无参考图生图请求统一走 VectorEngine `POST /v1/images/generations`有参考图请求走 `POST /v1/images/edits` multipart基础配置读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` / `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,上游模型使用 `gpt-image-2`,请求体不再携带 `official_fallback`。APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。
- 影响范围:`api-server` 共享图片 helper、拼图图片生成、角色主图、RPG 场景图、开局 CG 故事板、方洞视觉资产、生产环境示例、gpt-image-2 本地 skill 和相关技术文档。
- 验证方式:执行 `npm run check:encoding``cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml``cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml``cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml``cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml`,并用 `npm run dev:api-server` + `/healthz` 做后端 smoke。
- 关联文档:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md``docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`
## 2026-05-21 GPT-image-2 参考图统一走 edits multipart
- 背景:VectorEngine Apifox 创建 `api-446794806` 与编辑 `api-446794807` 明确区分无参考图创建和有参考图编辑;仓库旧实现曾把参考图塞入 `gpt-image-2` generations 的 `image` 数组,导致与供应商当前契约不一致。
- 决策:所有 GPT-image-2 无参考图生成调用 `POST /v1/images/generations`,所有有参考图生成调用 `POST /v1/images/edits`,模型固定 `gpt-image-2`,参考图作为 multipart `image` part 传入;仓库不再调用 `gpt-image-2-all`
- 影响范围:`api-server` 共享图片 helper、拼图图片生成、Match3D 封面重绘和容器 UI 图、gpt-image-2 本地 skill、玩法链路文档和后端架构文档。
- 验证方式:搜索仓库不应再出现 VectorEngine 图片编辑路径调用;执行 `cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml``cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml``cargo test -p api-server match3d_background --manifest-path server-rs/Cargo.toml`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-05-08 Hyper3D Rodin Gen-2 只通过后端安全代理接入
- 背景:需要接入 Hyper3D Rodin Gen-2 的文生 3D 模型与图生 3D 模型,但供应商 API Key 不能进入前端、文档或 Git;本次只是外部副作用代理,不需要新增平台真相表。
@@ -581,7 +600,7 @@
## 2026-05-10 视觉小说入口收敛为单句创作 + 画风选择
- 背景:视觉小说入口页要对齐抓大鹅式的线性创作入口,只保留最小可用输入,避免再暴露文档 / 空白 / 对话式工作台。
- 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风``画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`。画风卡片主视觉固定消费 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2-all` 生成的静态参考图,不在前端运行时现场调用生图接口。
- 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风``画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`。画风卡片主视觉固定消费 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2` 生成的静态参考图,不在前端运行时现场调用生图接口。
- 影响范围:`VisualNovelAgentWorkspace``visualNovelEntryGeneration``PlatformEntryFlowShellImpl`、视觉小说 PRD 和创作 Tab 设计文档;不新增后端字段或数据库结构。
- 验证方式:执行 `npm run test -- VisualNovelAgentWorkspace`、视觉小说工作台相关 ESLint、`npx prettier --check``npm run check:encoding``npm run typecheck` 若失败需先区分是否来自无关 Match3D / RPG 既有改动。
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`
@@ -605,7 +624,7 @@
## 2026-05-12 抓大鹅物品种类从消除次数中拆出并改为 2D 五视角素材
- 背景:结果页草稿素材已经能生成和预览,但标准 / 硬核难度仍可能按 `clearCount` 误判需要 12 / 20 种素材,且继续生产 GLB 会拉长草稿生成耗时。
- 决策:难度配置统一使用 `物品种类`:轻松 3、标准 9、进阶 15、硬核 21;历史硬核 `clearCount=20` 在运行态升为 21 组三消。新草稿和批量新增不再调用 Rodin、不再生成 GLB。每个物品生成 5 个不同 2D 视角,单张 1K 素材图固定按 5x5 切割,最多承载 5 个物品;超过 5 个物品时由 `api-server` 自动分批并行生图。发布必须校验已生成 `image_ready` 且有 `imageViews[]`首图引用的素材数量满足当前难度;试玩通过 `itemTypeCountOverride` 自动降到可用 2D 素材数量。历史模型字段只作为旧数据兼容,不再进入新生产链路。
- 决策:难度配置统一使用运行态 `物品种类`:轻松 3、标准 9、进阶 15、硬核 20;历史硬核 `clearCount=20` 在运行态升为 21 组三消,但类型池最多 20 种。新草稿和批量新增不再调用 Rodin、不再生成 GLB。每次固定从 `2K 1:1``10*10` 物品 spritesheet 解析并持久化 20 个物品、每个 5 个不同 2D 形态,物品信息列表全部展示 20 个;持久化行列索引按每行两种物品计算,不能超过 `1..=10`。发布必须校验已生成 `image_ready` 且有 `imageViews[]`首图引用或可解析的物品 spritesheet 满足当前难度;试玩通过 `itemTypeCountOverride` 自动降到可用 2D 素材数量。历史模型字段只作为旧数据兼容,不再进入新生产链路。
- 影响范围:Match3D 结果页、运行态启动契约、`module-match3d` 初始 run 生成、SpacetimeDB start input / restart、发布校验和 Match3D 技术文档。
- 验证方式:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`、相关后端 check / tests。
- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
+74 -22
View File
@@ -14,6 +14,32 @@
- 关联:相关文件、文档、提交或 Issue
```
## 抓大鹅新 UI spritesheet 不要回退成中心容器图
- 现象:新素材流程生成后,运行态棋盘中心可能叠出一整张 UI spritesheet,导致按钮素材、方格和空白图集覆盖容器区域。
- 原因:为了兼容旧 DTO,后端可能把 `uiSpritesheetImage*` 同步写入历史 `containerImage*` 字段;旧前端只看 `containerImage*`,会误把 UI 图集当透明中心容器。
- 处理:读取中心容器图时先比较归一化后的 `containerImage*``uiSpritesheetImage*`。两者同源时忽略 `containerImage*`,只把它作为旧数据兼容字段;新流程背景图本身已经保留容器,运行态只需加载背景和解析 UI / 物品 spritesheet。
- 验证:`npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx` 应覆盖“运行态不把兼容写入的UI spritesheet当中心容器图”。
- 关联:`src/components/match3d-runtime/Match3DRuntimeShell.tsx``server-rs/crates/api-server/src/match3d/mappers.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## UI spritesheet 不要依赖模型直接生成透明背景
- 现象:拼图或抓大鹅运行态解析 UI spritesheet 时,把整张背景图、棋盘格、叶子或装饰图也当作 UI 素材区域,按钮映射错乱;截图里常表现为底部按钮区只剩透明棋盘格或素材碎片。
- 原因:前端解析依赖 alpha 连通域检测,透明背景是前提;但生图模型收到“透明背景 spritesheet”提示后仍可能输出带实景背景或伪透明棋盘格的普通不透明 PNG,OSS 中保存的图没有真实 alpha。
- 处理:UI spritesheet 提示词应要求统一纯绿色绿幕背景,而不是让模型直接产透明背景;后端在上传 OSS 前复用 `generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(...)` 把绿幕扣成真实透明 PNG,再把透明图写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`
- 验证:`cargo test -p api-server puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent --manifest-path server-rs\Cargo.toml``cargo test -p api-server puzzle_level_scene_spritesheet_and_background_requests_use_references --manifest-path server-rs\Cargo.toml``cargo test -p api-server match3d_derived_asset_prompts_match_three_sheet_pipeline --manifest-path server-rs\Cargo.toml`
- 关联:`server-rs/crates/api-server/src/puzzle/generation.rs``server-rs/crates/api-server/src/match3d/works.rs``server-rs/crates/api-server/src/generated_asset_sheets.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 拼图 UI spritesheet 运行态不要二次包圆底或拉伸比例
- 现象:拼图运行态左上返回和右上设置按钮外面出现白色圆圈;底部“提示 / 原图 / 冻结”三枚素材被压扁、拉宽或拉成正圆,和图集原始按钮比例不一致。
- 原因:UI spritesheet 已经包含按钮视觉本体,但运行态仍给顶部按钮套默认圆形 icon 容器;底部三枚素材用 `h-full w-full rounded-full` 铺满按钮格,覆盖了自动检测矩形的真实宽高比。
- 处理:有 `uiSpritesheetImage*` 时,顶部返回 / 设置按钮容器只保留透明点击区和 focus 状态,不再叠加默认圆形底;`buildPuzzleUiSpriteBackgroundStyle(...)` 对检测到的矩形写入 `aspectRatio`,底部三枚素材按原始宽高比和最大尺寸渲染,不强制 `w-full`
- 验证:`npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx``npm run test -- src/services/puzzle-runtime/puzzleUiSpritesheetParser.test.ts`
- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/services/puzzle-runtime/puzzleUiSpritesheetParser.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
2026-05-22 补充:展示矩形和点击热区要分开处理。`puzzleUiSpritesheetParser``regions` 保留完整视觉裁切矩形,`hitRegions` 用较高 alpha 阈值只包住实心按钮主体;运行态底部 spritesheet 道具按钮启用 `puzzle-runtime-sprite-tool-button--precise-hit`,父按钮不吃整块透明留白,内部 `puzzle-runtime-ui-sprite-hit-zone` 才接收指针事件,避免透明区域成为点击热区。
## 图像输入组件不要把业务状态藏在页面内联实现里
- 现象:拼图页把参考图上传、缩略图、主图删除确认和 AI 重绘开关内联实现后,后续想复用到其它创作页时,页面级状态和通用 UI 状态混在一起,容易出现多套上传卡和参考图展示口径。
@@ -22,6 +48,14 @@
- 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。
- 关联:`src/components/common/CreativeImageInputPanel.tsx``src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`
## 弹窗里复用 CreativeImageInputPanel 要保留画面卡高度
- 现象:拼图草稿结果页的关卡详情弹窗中仍能看到“画面图”标题、画面描述和生成按钮,但实际画面图卡片视觉上消失。
- 原因:`CreativeImageInputPanel` 内部依赖 `flex-1``h-full``max-h-full` 撑开正方形画面卡;放进弹窗里的普通 `section` 后,父级没有可计算高度,卡片会被压到不可见。
- 处理:通用画面卡 `puzzle-image-upload-card` 保持 `aspect-square` 的同时设置稳定 `min-height`,让入口页和关卡详情弹窗都能显示主图/上传区。
- 验证:`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "opens an independent level detail dialog"` 应断言关卡详情中的 `.puzzle-image-upload-card` 具备最小高度类;`npm run test -- src/components/common/CreativeImageInputPanel.test.tsx` 应继续通过。
- 关联:`src/components/common/CreativeImageInputPanel.tsx``src/components/puzzle-result/PuzzleResultView.tsx``src/components/puzzle-result/PuzzleResultView.test.tsx`
## Windows provision 下载截断要断点续传而不是回退目标机下载
- 现象:`Genarrative-Server-Provision``Download Provision Tool Archives` 阶段出现 `curl: (18) end of response ... bytes missing`,常见于 `otelcol-contrib_0.151.0_linux_amd64.tar.gz` 等 GitHub release 大文件。
@@ -189,7 +223,7 @@
## 陶泥儿 logo 生图慢请求先缩短 prompt 并单张串行
- 现象:使用 VectorEngine `gpt-image-2-all` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。
- 现象:使用 VectorEngine `gpt-image-2` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。
- 原因:复杂抽象 logo prompt 同时包含品牌解释、禁用元素、中文结构和多重隐喻时,上游排队与生成时长不稳定;并发或批量运行会放大单条慢请求的影响。
- 处理:先 `--dry-run` 看请求体;真实生成时优先短 prompt、单一造型、单张串行或小批量。失败后不要反复重试同一长 prompt,先压缩到“一个主体 + 一个负形 + 颜色 + 禁用文字/播放键/聊天气泡”再跑。联系表中的中文标签不要通过 PowerShell 管道内联 Python 写入,容易因编码链路显示为问号,可改用英文标签或脚本文件方式。
- 验证:生成文件落在 `public/branding/taonier-logo-*/`,用 Pillow 检查图片尺寸和非空;执行 `node --check scripts/generate-taonier-logo-concepts.mjs``npm run check:encoding``git diff --check`
@@ -207,11 +241,11 @@
- 现象:点击生成抓大鹅草稿后,页面只提示“服务暂不可用”,或者本地 `npm run dev:api-server` 看似启动但生成接口不可用。
- 原因:配置缺失类错误通常在后端 `error.details.reason` 中给出具体缺项,前端如果只读 `details.message` 会吞掉原因;本地只配置 `ALIYUN_OSS_BUCKET` / `ALIYUN_OSS_ENDPOINT` 时,旧逻辑还会在启动期构造空 AccessKey 的 OSS 客户端并失败。抓大鹅新链路仍是 2D 生图切割,不需要也不应回退 Rodin/GLB。
- 处理:前端 API 错误展示优先读取 `details.reason`,再读取 `details.message`,避免底层 `error sending request` 覆盖真正可操作的配置或网络原因;`api-server` 只有在 OSS 四件套齐全时初始化 OSS 客户端,部分缺失只记 warning 并让具体 generated 上传/换签接口返回 `OSS 未完成环境变量配置`。抓大鹅素材、封面和背景生成在调用 VectorEngine 前先预检 OSS,并通过 `details.missingEnv` 列出缺项;真实生成需补齐 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 和完整 `ALIYUN_OSS_*` 四件套。抓大鹅 `5*5` 素材图提示词必须要求相邻物体主体至少保留 `1/4` 单格宽度空白间距,避免切割后相邻格内容污染
- 处理:前端 API 错误展示优先读取 `details.reason`,再读取 `details.message`,避免底层 `error sending request` 覆盖真正可操作的配置或网络原因;`api-server` 只有在 OSS 四件套齐全时初始化 OSS 客户端,部分缺失只记 warning 并让具体 generated 上传/换签接口返回 `OSS 未完成环境变量配置`。抓大鹅素材、封面和背景生成在调用 VectorEngine 前先预检 OSS,并通过 `details.missingEnv` 列出缺项;真实生成需补齐 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 和完整 `ALIYUN_OSS_*` 四件套。抓大鹅 UI spritesheet 和物品 spritesheet 的提示词必须要求纯绿色绿幕背景,后端上传 OSS 前统一扣成透明 PNG,避免运行态 alpha 连通域解析失败
- 验证:`npm run test -- src/services/apiClient.test.ts` 覆盖 `details.reason``cargo test -p api-server state --manifest-path server-rs/Cargo.toml` 覆盖半配置 OSS 不阻断启动;`npm run dev:api-server` 后按实际 `GENARRATIVE_API_PORT` 请求 `/healthz`,不要默认打 `3100`
- 关联:`packages/shared/src/http.ts``server-rs/crates/api-server/src/state.rs``docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md``docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md`
2026-05-14 补充:抓大鹅“物品素材 sheet”已改用 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,真实生成读取 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;封面和 `9:16` 背景图走 VectorEngine `/v1/images/generations``1:1` 容器 UI 走 VectorEngine `/v1/images/edits` multipart 参考图链路。排查素材 sheet 时看请求路径是否为 `/v1beta/models/gemini-3-pro-image-preview:generateContent?key=...`,响应图片在 `candidates[].content.parts[].inlineData.data` / `inline_data.data`,不要再按 APIMart `/images/generations``/tasks/{task_id}` 排查
2026-05-22 补充:抓大鹅“物品 spritesheet”不再按旧 Gemini `generateContent` / `5*5` sheet 路径排查;当前链路先用 `gpt-image-2` 无参考图生成 `9:16` 关卡整图,再以该关卡整图作为 multipart `image` 参考并发编辑生成 `1K 1:1` UI spritesheet、`1K 9:16` 背景图和 `2K 1:1` 物品 spritesheet。UI 与物品 spritesheet 都要求纯绿色绿幕背景,上传 OSS 前通过后端透明化处理写入真实 alpha PNG
## 抓大鹅发布按钮要先开发布面板,封面编辑收口到发布面板内
@@ -305,7 +339,7 @@
## 儿童动作 Demo 绘本风资源未生成先查 VectorEngine 配置
- 现象:`/child-motion-demo` 已经呈现绘本草地风格,但 `public/child-motion-demo/picture-book-grass-stage.png``picture-book-grass-floor.png``picture-book-ground-ring.png``picture-book-character-outline.png``picture-book-ui-panel.png``picture-book-ui-button.png` 不存在,Network 里对应图片返回 404,或运行 `npm run assets:child-motion-demo -- --live` 返回缺少 VectorEngine 配置。
- 原因:儿童动作 Demo 的真实背景、地面、UI、地面指示环和角色轮廓资源都使用 VectorEngine `gpt-image-2-all` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key,缺配置时页面只能使用 CSS 草地绘本兜底。
- 原因:儿童动作 Demo 的真实背景、地面、UI、地面指示环和角色轮廓资源都使用 VectorEngine `gpt-image-2` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key,缺配置时页面只能使用 CSS 草地绘本兜底。
- 处理:在本地私密环境补齐 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai``VECTOR_ENGINE_API_KEY`,不要把 key 写入 Git;先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt,再运行 `npm run assets:child-motion-demo -- --live``npm run assets:child-motion-demo -- --live --only ui-panel` 等小批量命令生成资源。透明资源的品红底源图写入 `tmp/child-motion-demo-assets/`,不要把源图或预览图放入 `public/child-motion-demo/` 作为正式资产。
- 验证:生成后确认 `public/child-motion-demo/` 只保留页面引用的最终 PNG,重新打开 `/child-motion-demo` 可看到真实绘本草地背景、地面、圆环、角色轮廓和 UI 资源;`npm run check:encoding` 仍通过。
- 关联:`scripts/generate-child-motion-demo-assets.mjs``src/index.css``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
@@ -329,8 +363,8 @@
## GPT-image-2 不再读 APIMart 图片配置
- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`
- 原因:2026-05-09 后 GPT-image-2 图片生成已切到 VectorEngine `gpt-image-2-all`APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。
- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认路径为 `/v1/images/generations`、模型为 `gpt-image-2-all`、参考图字段为 `image`
- 原因:2026-05-21 后 GPT-image-2 图片生成 VectorEngine 创建/编辑接口分流APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。
- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`有参考图路径为 `/v1/images/edits`模型为 `gpt-image-2`
- 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。
- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md``server-rs/crates/api-server/src/openai_image_generation.rs`
@@ -350,19 +384,19 @@
- 验证:后端单测覆盖 `build_puzzle_levels_with_primary_update``apply_generated_puzzle_candidates_to_session_snapshot`;结果页重新生成应在未重新上传时继续带入 `level.pictureReference`
- 关联:`server-rs/crates/api-server/src/puzzle.rs``src/components/puzzle-result/PuzzleResultView.tsx`
## 拼图图生图仍不像参考图时先看是否走了 edits
## 拼图参考图不像时先看 edits multipart image
- 现象:Network payload 已带 `referenceImageSrc`,但 VectorEngine 生成结果仍明显不像上传图。
- 原因:`gpt-image-2-all` `/v1/images/generations` 更适合纯文生图;有参考图且需要重绘时应切到 `/v1/images/edits` 的 multipart 图生图接口
- 处理:`referenceImageSrc` 存在且 `aiRedraw = true`直接走 editsprompt 保留参考图强约束;入口页关闭 AI 重绘时直接应用上传图,不调用图片生成;前端把参考图压到单边 1024 内,后端解析后拒绝超过 8MB 的参考图字节。
- 验证:后端单测应覆盖 `images/edits` 路由、`b64_json` 响应解码和参考图强提示;真实联调看日志里是否命中 `拼图 VectorEngine 图片编辑 HTTP 返回`
- 原因:参考图只在 `aiRedraw = true` 时由后端解析并传给 `gpt-image-2` `/v1/images/edits` 的 multipart `image` part;若前端没传 `referenceImageSrc`、后端解析失败或 prompt 缺少参考图强约束,生成会退化为纯文生图
- 处理:`referenceImageSrc` 存在且 `aiRedraw = true` 时走 edits multipart,prompt 保留参考图强约束;入口页关闭 AI 重绘时直接应用上传图,不调用图片生成;前端把参考图压到单边 1024 内,后端解析后拒绝超过 8MB 的参考图字节。
- 验证:后端单测应覆盖 `/v1/images/edits` 路由、`b64_json` 响应解码和参考图强提示;真实联调看日志里是否命中 `拼图 VectorEngine 图片编辑 HTTP 返回`
- 关联:`server-rs/crates/api-server/src/puzzle.rs``src/services/puzzleReferenceImage.ts``docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`
## 拼图 edits 报 error sending request 先看网络分类
- 现象:拼图有参考图时返回 `拼图图片生成失败:创建拼图 VectorEngine 图片编辑任务失败:error sending request for url (https://api.vectorengine.ai/v1/images/edits)`,后端没有 `拼图 VectorEngine 图片编辑 HTTP 返回` 日志。
- 原因:这是 `reqwest``send()` 阶段失败,尚未收到 VectorEngine HTTP 响应;常见原因是服务器网络 / DNS / 防火墙 / 代理问题,或上游网关中断 multipart 连接。
- 处理:查看错误响应 `details.reason/source/connect/body/timeout/endpoint``拼图 VectorEngine 请求发送失败` 日志。拼图图片客户端已强制 HTTP/1.1,降低 multipart HTTP/2 兼容风险;若 `connect=true` 先查网络出口,若 `body=true` 先查参考图大小和 multipart 发送
- 处理:查看错误响应`拼图 VectorEngine 图片编辑` 相关日志;若请求发送阶段失败,先查网络出口、DNS、防火墙、代理、参考图大小和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`
- 验证:`curl --http1.1 -i -X POST https://api.vectorengine.ai/v1/images/edits -H "Authorization: Bearer invalid" -F "model=gpt-image-2" -F "prompt=test" -F "n=1" -F "size=1024x1024" -F "image=@public/match3d-background-references/pot-fused-reference.png;type=image/png"` 至少应返回 HTTP `401`,说明域名、TLS、路径和 multipart 上传可达;执行 `cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/puzzle.rs``docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md``docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`
@@ -393,7 +427,7 @@
## 拼图草稿生成 180 秒后 502/504 先查 VectorEngine 超时与前端重试
- 现象:点击“生成拼图游戏草稿”后,`POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions` 等待约 180 秒返回 `502 Bad Gateway``504 Gateway Timeout`;钱包流水里同一 session 可能出现连续两组 `puzzle_initial_image` 扣费后退款。
- 原因:首图生成走 VectorEngine `gpt-image-2-all`,默认 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000`;若上游在该窗口内未返回,后端退款并返回超时错误。旧前端 action 写请求会对 502/503/504 自动重试一次,导致同一次点击重复触发生图与扣退费。
- 原因:首图生成走 VectorEngine `gpt-image-2`,默认 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000`;若上游在该窗口内未返回,后端退款并返回超时错误。旧前端 action 写请求会对 502/503/504 自动重试一次,导致同一次点击重复触发生图与扣退费。
- 处理:拼图/创作 Agent 的 `executeAction` 默认不做前端自动重试;后端将 VectorEngine / 图片请求超时映射为 `504 Gateway Timeout``error.details.provider=vector-engine``timeout=true`。真实排障按日志同一 `session_id``拼图 VectorEngine 图片生成 HTTP 返回` 是否缺失,以及钱包流水扣费到退款的时间差是否接近 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`
- 验证:运行 `npm run test -- src/services/creation-agent/creationAgentClientFactory.test.ts src/services/apiClient.test.ts``cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`,真实联调重启 `npm run dev:api-server` 后检查 `/healthz`
- 关联:`src/services/creation-agent/creationAgentClientFactory.ts``server-rs/crates/api-server/src/puzzle.rs``docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`
@@ -403,7 +437,7 @@
- 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已经设置较长的 AbortController 超时,但仍在约 180 到 300 秒后抛 `AbortError``TypeError: fetch failed``UND_ERR_HEADERS_TIMEOUT`;同一 prompt 改用原生 `https.request` 可以在较短时间内成功返回图片。
- 原因:Node/Undici 的默认 headers timeout 可能早于业务脚本期望的长生图等待窗口触发,表现上容易被误判成 VectorEngine 上游本身超时。
- 处理:长期脚本优先复用后端 reqwest 或项目已有生成脚本;临时本地工具若必须用 Node,可改用原生 `http`/`https.request` 并显式设置 socket timeout,或为 Undici 单独配置 headers timeout。仍需隐藏 `VECTOR_ENGINE_API_KEY`,只报告配置是否存在。
- 验证:同一 `gpt-image-2-all` 请求体、同一环境变量下,原生 HTTP 请求能返回 `url` / `b64_json` 并落盘;失败时错误里能区分请求发送、首部等待、下载和解码阶段。
- 验证:同一 `gpt-image-2` 请求体、同一环境变量下,原生 HTTP 请求能返回 `url` / `b64_json` 并落盘;失败时错误里能区分请求发送、首部等待、下载和解码阶段。
- 关联:`.codex/skills/gpt-image-2-apimart/SKILL.md``server-rs/crates/api-server/src/openai_image_generation.rs`
## 旧后端路线文档造成判断漂移
@@ -820,7 +854,7 @@
- 现象:修改抓大鹅素材时容易沿用旧 Rodin/GLB 方案,导致新草稿生成耗时变长、进度停在模型阶段,或运行态等待不存在的 GLB。
- 原因:仓库里保留了 Hyper3D 通用代理和历史模型字段,旧文档也曾要求草稿阶段同步生成 GLB。当前产品口径已经改为 2D 多视角素材。
- 处理:新 `match3d_compile_draft` 与批量新增只生成 2D 图片:每个物品 5 个视角,单张 1K 素材图固定 5x5,最多承载 5 个物品,一行对应一个物品,不足 5 个物品也补齐到完整 5 行;超过 5 个物品自动分批并行生图。素材图 prompt 固定要求纯绿色绿幕背景,切割前先把绿幕处理为透明 alpha,再做格内内容前景边界校准并带留白,避免固定内缩切掉贴近格线的主体。`generatedItemAssets[].status` 使用 `image_ready`,发布校验看 `imageViews[]`首图引用。`generated-models` 仅用于历史外部模型链接转存,不能作为新生产链路。
- 处理:新 `match3d_compile_draft` 与批量新增只生成 2D 图片:每个物品 5 个形态,单张 `2K 1:1` 物品 spritesheet 固定 `10*10`,每行承载两种物品、每种五个形态,单张最多承载 20 种物品。素材图 prompt 固定要求纯绿色绿幕背景,上传 OSS 前先把整张 spritesheet 绿幕处理为透明 alpha,再由运行态和编辑器按 alpha 连通域解析;`generatedItemAssets[].status` 使用 `image_ready`,发布校验看 `imageViews[]`首图引用或可解析的物品 spritesheet`generated-models` 仅用于历史外部模型链接转存,不能作为新生产链路。
- 验证:`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``npm run test -- src\services\miniGameDraftGenerationProgress.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`
- 关联:`server-rs/crates/api-server/src/match3d.rs``src/components/match3d-runtime/Match3DRuntimeShell.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
@@ -856,11 +890,11 @@
- 验证:执行 `npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "Match3D runtime"`;浏览器 Network 中背景和容器 generated path 应先请求 `/api/assets/read-url` 换签,局内出现 `match3d-background-image``match3d-container-image` 对应图片。
- 关联:`src/components/match3d-runtime/Match3DRuntimeShell.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/rpgEntryWorldPresentation.ts``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅容器参考图必须 edits 并接管棋盘外观
## 抓大鹅容器参考图必须进入 edits multipart image 并接管棋盘外观
- 现象:抓大鹅结果页看似有容器生成入口,但真实生成出的局内容器不像 `pot-fused-reference.png`,或进入试玩后仍被默认圆形锅壳、金色边框和径向底色覆盖/裁切。
- 原因:`/v1/images/generations``image` 数组更适合弱参考文生图,难以稳定锁定大尺寸轻俯视容器构图;即使生成了容器图,如果运行态继续保留默认 `rounded-full` 锅壳和 `overflow-hidden`,生成图也会被默认视觉覆盖或裁掉。
- 处理:抓大鹅 `1:1` 容器 UI 图必须用 VectorEngine `POST /v1/images/edits` multipart,参考 `public/match3d-background-references/pot-fused-reference.png` 的透明容器图作为 `image` part;该参考图属于后端生图协议输入,需通过 `include_bytes!` 编译进 `api-server`,不能在运行时按当前工作目录读取 `public/`共享 GPT-image-2 HTTP client 承载 multipart 时强制 HTTP/1.1。`Match3DRuntimeShell` 在容器图换签并成功加载后,把棋盘外壳切为透明和 `overflow-visible`,只在容器缺失或加载失败时使用默认圆形容器。
- 原因:容器参考图必须进入 `gpt-image-2` `/v1/images/edits` multipart `image` part,并配合强 prompt 锁定大尺寸轻俯视容器构图;即使生成了容器图,如果运行态继续保留默认 `rounded-full` 锅壳和 `overflow-hidden`,生成图也会被默认视觉覆盖或裁掉。
- 处理:抓大鹅 `1:1` 容器 UI 图统一调用 VectorEngine `POST /v1/images/edits`,参考 `public/match3d-background-references/pot-fused-reference.png` 的透明容器图由后端作为 `image` part 上传;该参考图属于后端生图协议输入,需通过 `include_bytes!` 编译进 `api-server`,不能在运行时按当前工作目录读取 `public/``Match3DRuntimeShell` 在容器图换签并成功加载后,把棋盘外壳切为透明和 `overflow-visible`,只在容器缺失或加载失败时使用默认圆形容器。
- 验证:执行 `cargo test -p api-server vector_engine --manifest-path server-rs/Cargo.toml``cargo test -p api-server match3d_background --manifest-path server-rs/Cargo.toml``npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`;真实联调看容器生成请求是否命中 `/v1/images/edits`,局内 `match3d-container-image` 是否渲染且 `match3d-board` 不再含默认 `rounded-full`
- 关联:`server-rs/crates/api-server/src/openai_image_generation.rs``server-rs/crates/api-server/src/match3d.rs``src/components/match3d-runtime/Match3DRuntimeShell.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
@@ -938,9 +972,9 @@
## 抓大鹅难度配置的物品种类和消除次数必须分离
- 现象:历史草稿选择标准 / 硬核难度后,系统可能把 `clearCount` 当成局内物品种类数量,导致标准需要 12 种、硬核需要 20/21 种;素材不足时发布或试玩行为不一致
- 现象:历史草稿选择标准 / 硬核难度后,系统可能把 `clearCount` 当成局内物品种类数量,导致标准需要 12 种、硬核需要 20/21 种;或者把第 11 到 20 个物品持久化为第 11 到 20 行,触发“系列素材图集持久化的行列索引必须落在 n*n 范围内”
- 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。
- 处理:统一使用 `物品种类` 口径轻松 3、标准 9、进阶 15、硬核 21历史 `clearCount=20` 且难度为硬核的运行态按新硬核升为 21 组三消,避免 20 组却要求 21 种素材。发布前按 `image_ready` 且有 `imageViews[]``imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。
- 处理:生成和持久化固定使用 20 个物品素材;运行态物品种类口径轻松 3、标准 9、进阶 15、硬核 20历史 `clearCount=20` 且难度为硬核的运行态仍可升为 21 组三消,但类型池不超过 20。10*10 sheet 每行两种物品、每种五个形态,持久化行列为 `row = itemIndex / 2 + 1``col = itemIndex % 2 * 5 + viewIndex + 1`。发布前按 `image_ready` 且有 `imageViews[]``imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx``cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`,涉及发布 reducer 时补跑 `cargo test -p spacetime-module match3d --manifest-path server-rs\Cargo.toml`
- 关联:`src/components/match3d-result/Match3DResultView.tsx``src/services/match3d-runtime/match3dRuntimeClient.ts``server-rs/crates/module-match3d/src/application.rs``server-rs/crates/spacetime-module/src/match3d.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
@@ -957,7 +991,7 @@
- 现象:抓大鹅生成的物品视角图裁剪后仍带白边,或者整块纯绿色绿幕背景没有被透明化,运行态看到绿色方块。
- 原因:素材 sheet 可能是“每格内部绿幕、整张图外圈近白底”,内部绿幕不一定连通到 sheet 外边缘;旧 flood fill 只从外边缘找背景会漏掉这种绿幕块。白底抗锯齿如果不纳入抠像和边缘去污染,也会随裁剪输出成一圈白边。即使顺序已是先整张 sheet 去绿再裁剪,较厚的半透明或混色软绿边仍可能低于高置信绿幕阈值,被当作前景带进独立 PNG。
- 处理:`api-server``slice_match3d_material_sheet` 必须先在整张 sheet 上做透明背景后处理:外边缘连通绿幕/近白底清 alpha,非连通但高置信纯绿块也清 alpha,沿整张 sheet 透明背景继续吃掉软绿边,边缘近白和绿幕抗锯齿做透明或去污染;同时保护不够纯的绿色主体像素。不要改成先裁剪单格再去绿。
- 验证:`cargo test -p api-server match3d_material_sheet_slicing --manifest-path server-rs\Cargo.toml` 覆盖非连通绿幕、白边、贴边主体保留和固定 5x5 切图
- 验证:`cargo test -p api-server match3d_material_sheet_slicing --manifest-path server-rs\Cargo.toml` 覆盖非连通绿幕、白边、贴边主体保留和固定 `10*10` 切图;`cargo test -p api-server match3d_spritesheet_green_screen_postprocess_turns_background_transparent --manifest-path server-rs\Cargo.toml` 覆盖完整 spritesheet 上传前绿幕透明化
- 关联:`server-rs/crates/api-server/src/match3d.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅物品详情大方格只做单张大图查看
@@ -1024,6 +1058,14 @@
- 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "拖拽合并大块时底层单格不显示选中色块"`,并确认合并块拖拽时底层 `[data-piece-id]` 仍为 `puzzle-runtime-piece--merged`
- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx``docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`
## 推荐页嵌入拼图通关结算不要放在运行态内部 absolute 层
- 现象:推荐页里玩拼图通关后,结算面板只显示上半部分,排行榜、下一关按钮或相似作品卡被截断。
- 原因:推荐页把运行态放在滑动作品卡的视觉区内,`platform-recommend-swipe-page``platform-recommend-swipe-card__visual``platform-recommend-runtime-viewport` 都是 `overflow: hidden`;拼图通关结算如果仍是运行态内部 `absolute inset-0` 弹层,就只能在半屏卡片区域里显示。
- 处理:`PuzzleRuntimeShell``embedded` 模式下把通关结算层通过 portal 挂到 `document.body`,使用 `puzzle-runtime-modal-overlay--fixed` 页面级 fixed 浮层;非嵌入态继续使用运行态内部覆盖层。
- 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪"`,确认弹层不再位于 `.platform-recommend-runtime-viewport` 内。
- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/index.css``src/components/rpg-entry/RpgEntryHomeView.tsx`
## 拼图历史图片列表不要把账号归属当图片名
- 现象:拼图创作页或结果页打开“选择历史图片”后,历史列表显示 `账号 user-1` 之类归属文案而不是图片名;`1713686400.000000Z` 这类时间显示为未知;选中后预览或生成参考图可能被怀疑不可用。
@@ -1042,12 +1084,22 @@
## 拼图结果页局部生图不要污染草稿生成态
- 现象:拼图草稿已经生成完成后,在结果页重新生成 UI 背景或追加关卡生成图片,草稿页仍显示整卡“生成中”,点击草稿会回到生成过程页,无法查看已有结果;UI 背景生成中还会禁用“新增关卡”和关卡图生成
- 现象:拼图草稿已经生成完成后,在结果页重新生成关卡图片或追加关卡生成图片,草稿页仍显示整卡“生成中”,点击草稿会回到生成过程页,无法查看已有结果;关卡图片生成中还会禁用“新增关卡”和其它关卡详情编辑
- 原因:结果页局部 action 复用了全局 `isPuzzleBusy` / 持久化 `generationStatus=generating` 语义,作品架没有区分“初始草稿不可查看”和“已有结果上的局部关卡生成”。
- 处理:作品架只在拼图没有可用封面、首关候选图或任一可查看关卡时才把 `generationStatus=generating` 解释为初始草稿生成;结果页 UI 背景和关卡图走 background action,不设置全局 busyUI 背景只禁用自己的按钮SpacetimeDB/API mapper 读写时把已有图片但状态仍是 `generating` 的历史关卡归一为 `ready`
- 处理:作品架只在拼图没有可用封面、首关候选图或任一可查看关卡时才把 `generationStatus=generating` 解释为初始草稿生成;结果页关卡图走 background action,不设置全局 busy只标记对应关卡局部生成进度SpacetimeDB/API mapper 读写时把已有图片但状态仍是 `generating` 的历史关卡归一为 `ready`
- 验证:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx``cargo test -p api-server puzzle --manifest-path server-rs\Cargo.toml`
- 关联:`src/components/custom-world-home/creationWorkShelf.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/puzzle-result/PuzzleResultView.tsx``server-rs/crates/api-server/src/puzzle/mappers.rs``server-rs/crates/spacetime-module/src/puzzle.rs`
2026-05-22 补充:结果页关卡详情的“关卡测试”不能把单关 `draft` 传给父级再调用 `updatePuzzleWork``updatePuzzleWork` 会同步 `puzzle_work_profile.levels_json` 和 source session 草稿,单关快照会把整份多关卡草稿覆盖成一个关卡,退出重进后只剩最后测试的关卡且序号表现为第一关。修复口径是 `PuzzleResultView` 始终传完整 `syncedDraft`,额外用 `{ levelId }` 指定起始关卡;父级持久化完整 levels 后调用 `startLocalPuzzleRun(item, levelId)`
## 拼图上传图关闭 AI 重绘不要走首图生图
- 现象:用户在拼图入口页或结果页关卡详情上传图片并关闭 AI 重绘后,生成页仍显示“生成拼图首图”,或者后端仍调用 `generate_puzzle_image_candidates` 生成第一张 1:1 候选图。
- 原因:上传图直用路径应把 Data URL 或 `/generated-*` 历史图解析后持久化为 `sourceType=uploaded` 的正式候选,再继续生成 9:16 关卡画面、UI spritesheet 和纯背景;如果只把 `aiRedraw=false` 当作“不参考图片生成”,就会误走首图生成。
- 处理:入口页用 payload 的 `aiRedraw` 写入生成页 metadata`puzzleAiRedraw=false` 时进度跳过 `生成拼图首图`;后端 `compile_puzzle_draft` 和结果页 `generate_puzzle_images` 都在 `aiRedraw=false && referenceImageSrc 非空` 时走上传图直用候选。结果页关卡详情必须复用 `CreativeImageInputPanel`,不要把正式图当成可重绘参考图;本次上传或历史选择的图才显示 AI 重绘开关并可删除。
- 验证:`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts src/components/puzzle-result/PuzzleResultView.test.tsx``cargo test -p api-server puzzle_result_level_direct_upload_skips_cover_image_generation --manifest-path server-rs\Cargo.toml`
- 关联:`src/services/miniGameDraftGenerationProgress.ts``src/components/puzzle-agent/PuzzleAgentWorkspace.tsx``src/components/puzzle-result/PuzzleResultView.tsx``server-rs/crates/api-server/src/puzzle/draft.rs``server-rs/crates/api-server/src/puzzle/generation.rs`
## Jenkins 数据库导入导出脚本先补 Node 工具链 PATH
- 现象:`Genarrative-Database-Import``Genarrative-Database-Export` 运行到迁移脚本时,`bash``node: command not found`,常见在日志里表现为某个 `sh` 块内第 61 行直接调用 `node` 失败。
@@ -1,4 +1,4 @@
# 宝贝识物寓教于乐模板 PRD 2026-05-11
# 宝贝识物寓教于乐模板 PRD 2026-05-11
## 1. 目标
@@ -32,7 +32,7 @@
5. 游戏视觉主题包;
6. 作品标签。
素材使用 VectorEngine `gpt-image-2-all` / image-2 生成。图片生成只能走后端接口,前端不得读取、拼接或暴露 `VECTOR_ENGINE_API_KEY`
素材使用 VectorEngine `gpt-image-2` / image-2 生成。图片生成只能走后端接口,前端不得读取、拼接或暴露 `VECTOR_ENGINE_API_KEY`
为降低生成成本,创作提交后只生成两张原始图片:一张 `2x2` 素材 sheet 和一张单独场景背景图。`2x2` 素材 sheet 固定包含左上物品 A、右上物品 B、左下篮子、右下礼物盒。服务端必须按固定格切图,并把物品、篮子和礼物盒转成透明 PNG。只有透明抠图后的两个物品素材才允许写入草稿 `itemAssets` 并进入游戏运行态。左右手位置指示器属于运行态默认规则,使用项目内置静态素材,不在每次创作时生成。
@@ -1,4 +1,4 @@
# 宝贝识物创作发布实现方案 2026-05-11
# 宝贝识物创作发布实现方案 2026-05-11
## 1. 范围
@@ -144,7 +144,7 @@ PUT /api/creation/edutainment/baby-object-match/drafts/{draftId}
POST /api/creation/edutainment/baby-object-match/drafts/{draftId}/publish
```
图片生成必须在后端调用 VectorEngine `gpt-image-2-all`,不得从前端直接调用外部图片接口。
图片生成必须在后端调用 VectorEngine `gpt-image-2`,不得从前端直接调用外部图片接口。
后端 `2x2` 素材 sheet prompt 约束:
@@ -1,4 +1,4 @@
# 儿童动作识别互动玩法 Demo 热身关开发规格文档
# 儿童动作识别互动玩法 Demo 热身关开发规格文档
> 日期:2026-05-09
> 关联设计文档:[CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md](../design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md)
@@ -684,7 +684,7 @@
1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。
2. 该卡通绘本草地风格是儿童动作 Demo 后续场景、物品、UI 资源的全局风格要求;新增资源不得切回暗色科技风、真实照片风或后台面板风。
3. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格接入真实资源;资源加载失败时保留 CSS 兜底。
4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2-all` 调用 VectorEngine `POST /v1/images/generations`,透明资源先生成品红底源图,再在本地移除色键,源图写入 `tmp/child-motion-demo-assets/`
4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2` 调用 VectorEngine `POST /v1/images/generations`,透明资源先生成品红底源图,再在本地移除色键,源图写入 `tmp/child-motion-demo-assets/`
5. 当前已生成并接入以下正式 Demo 资源:
- `public/child-motion-demo/picture-book-grass-stage.png`:默认草地舞台背景。
- `public/child-motion-demo/picture-book-foreground-grass-v2.png`:底部前景草坪条,只覆盖舞台下沿,不作为整块地板拉伸。
@@ -214,7 +214,7 @@ Handler 主要在 `story.rs`、`combat.rs`、`runtime_inventory.rs`
| Big Fish 正式图 | DashScope `wan2.2-t2i-flash` | 轮询 task 后 HTTP GET 图片 URL | `LegacyAssetPrefix::BigFishAssets` | 由 assetKind 映射主图/动作图/舞台背景等 | `big_fish_session` + session/entity id + slot | `big_fish.rs` 调用方 `execute_billable_asset_operation` | 配置缺失/上游失败直接错误;gallery 对部分 Spacetime 运行错误软降级 |
| Square Hole 图片重生成 | OpenAI/VectorEngine GPT image helper | URL 下载或 base64/data URL 解码 | `LegacyAssetPrefix::SquareHoleAssets` | 方洞作品图片槽位相关 kind | profile/work + image slot | 调用方包裹 | 生成成功但入库失败保留 Data URL 回包 |
| Custom World 场景/封面 | VectorEngine GPT image 2 / OpenAI helper | URL 下载或 base64 解码 | `LegacyAssetPrefix::CustomWorldScenes` 等 | scene/cover/opening storyboard | `custom_world_profile` 或 profile/landmark/scene slot | `custom_world_ai.rs` 调用方包裹 | entity/scene 生成存在 LLM fallback;资产持久化失败按当前错误口径返回 |
| Puzzle 图片 | GPT image 2 generations/edits | multipart/base64/URL 结果归一 | `LegacyAssetPrefix::PuzzleAssets` | puzzle level/background/generated image,另有 `puzzle_background_music` | puzzle profile/run/level slot | `puzzle.rs` 调用方包裹 | connectivity 可按既有规则跳过部分计费;运行态 fallback 保持原逻辑 |
| Puzzle 图片 | GPT image 2 generations/edits | 无参考图 JSON 创建;有参考图 multipart 编辑;base64/URL 结果归一 | `LegacyAssetPrefix::PuzzleAssets` | puzzle level/background/generated image,另有 `puzzle_background_music` | puzzle profile/run/level slot | `puzzle.rs` 调用方包裹 | connectivity 可按既有规则跳过部分计费;运行态 fallback 保持原逻辑 |
| Match3D 图片 | APIMart/VectorEngine/OpenAI image helper | 下载、切图、透明化、校准后入库 | `LegacyAssetPrefix::Match3DAssets` | cover/background/item material sheet,音频 kind 另列 | match3d profile/session slot | `match3d.rs` 调用方包裹 | 新草稿不回退 Rodin/GLB;部分连接错误按现有计费跳过规则处理 |
| Visual Novel 音频 | VectorEngine Suno/Vidu | 任务提交后按 task publish 下载音频 | 视觉小说/creation audio scope | `visual_novel_music``visual_novel_ambient_sound` | `visual_novel_scene` + scene id + `music`/`ambient_sound` | `vector_engine_audio_generation.rs` 调用方包裹 | 上游/下载失败显式错误,不混入图片 Adapter |
| 通用音频 | VectorEngine Suno/Vidu | 同上 | creation audio scope | background_music/sound_effect 由调用方目标指定 | creation target entity/slot | 调用方包裹 | 不与 VN 场景语义混用 |
@@ -105,7 +105,7 @@ npm run check:server-rs-ddd
- `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。
- `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。
- `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品生成批次编排、append / replace / delete / sort / merge、计费外层和草稿素材映射;sheet prompt、绿幕 / 近白底透明化、切图和切片持久化复用 `generated_asset_sheets` 通用模块。
- `server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs` 承接 VectorEngine Gemini 请求体、响应解析、base64 图片下载和上游错误归一
- `server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs` 仅保留历史 VectorEngine Gemini 物品 sheet helper;当前草稿物品 spritesheet 以关卡整图为参考走 `gpt-image-2` 编辑链路,提示词、绿幕透明化和 OSS 持久化由 `item_assets.rs` / `works.rs` 约束
- `server-rs/crates/api-server/src/match3d/runtime.rs` 保留运行态轻量归一 helper`mappers.rs` / `tags.rs` / `tests.rs` 分别承接 DTO 映射、标签 / 通用错误 helper 和原有单测。
该拆分只改变 `api-server` 文件组织,不改变 `/api/creation/match3d/*``/api/runtime/match3d/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义、VectorEngine / OSS 副作用边界或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉到 `module-match3d`
@@ -156,9 +156,8 @@ npm run check:server-rs-ddd
- LLM`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`
- 图片生成:VectorEngine / APIMart / DashScope,密钥只在后端环境变量中。
- Match3D 物品 sheetVectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`;图集 prompt、切图、透明化和切片持久化走 `generated_asset_sheets` 通用模块,Match3D 只补题材 / 风格 / 五视角设定和字段映射
- Match3D 封面和 9:16 纯背景:VectorEngine `/v1/images/generations`
- Match3D 1:1 容器 UIVectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!``api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
- Match3D 物品 sheet关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2``2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端固定从该 sheet 解析并持久化 20 个物品、每个 5 个形态;通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG
- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;新 Match3D 草稿和批量新增不再生成 GLB。
- 音频:视觉小说专用音频路由保留;拼图和抓大鹅生成入口暂时关闭,通用 `/api/creation/audio/*` 对相关目标返回 `410 Gone`
- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`
@@ -10,6 +10,8 @@
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`
移动端底部一级导航是全局平台样式,不按单一玩法分叉。当前视觉统一为米白浮动胶囊底座、浅棕分隔线、棕色线性图标、橘色选中态和底部短下划线;中间 `创作` 入口保持凸起圆形主按钮,但凸起位移只能作用在按钮内容层,不能移动承载分隔线的 Tab 按钮容器,确保创作左右分隔线与其他分隔线垂直位置一致。Tab 名称和可见性仍由现有 `PlatformHomeTab` / 登录态规则决定,样式调整不得改写 Tab 文案或导航状态。
## 新增玩法创作工具平台 SOP
新增玩法默认采用表单/图片输入创作工作台,链路为:
@@ -45,23 +47,26 @@
当前口径:
- 图像输入复用 `CreativeImageInputPanel`
- 结果页每关画面编辑和素材配置里的 UI 背景生成也复用 `CreativeImageInputPanel`三处只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`UI 背景写 `levels[0].uiBackgroundPrompt/uiBackgroundImage*` 并触发 `generate_puzzle_ui_background`。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在 `displayImageSrc` 自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`
- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端换取 OSS 只读签名 URL VectorEngine 读取;本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入。
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`首关关卡图和 UI 背景在命名稳定后并行启动,当前不自动生成背景音乐。
- 结果页每关画面编辑复用 `CreativeImageInputPanel`入口页和关卡画面只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`。结果页删除独立“素材配置”Tab,不再提供单独 UI 背景生成入口。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在正式图自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`用户在本次编辑中上传或选择历史图后,该图优先占据主图卡片,可删除、切换 AI 重绘,也可关闭 AI 重绘直用;仅有正式图预览时,画面描述框仍可上传多张参考图。关卡详情弹窗应使用加宽面板,关卡名称、画面图和画面描述合并在同一个纵向列表中,名称输入和画面编辑模块外层不再包独立 `platform-subpanel`;画面图卡仍必须保留稳定最小高度,避免弹窗内 `flex-1` 布局坍缩后只剩标题、描述输入和操作按钮。
- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景后再变为 `ready`;当前不自动生成背景音乐。生成页进度不再按固定 5 分钟展示,而按实际开始时间和当前路径的分步骤预计时长推进;任一同步 action 回包到达时立即以真实完成/失败结果冻结进度
- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。
- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_000_000ms` 且不自动重试,生成页预计完成时间按 `5` 分钟展示;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。
- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`30 分钟)且不自动重试。每次 `gpt-image-2` 调用的预期用时按 90 秒计算;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 90 秒、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 298 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面``生成UI与背景`,合计约 208 秒。生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。
- 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。
- 拼图参考图 AI 重绘优先走 VectorEngine `/v1/images/edits`若编辑接口超时,`api-server` 会降级为 `/v1/images/generations`,并把同一参考图塞进 `image` 数组继续生成,避免参考图草稿整单失败
- 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏
- 拼图参考图 AI 重绘走 VectorEngine `/v1/images/edits`无参考图时走 `/v1/images/generations`。两者模型都使用 `gpt-image-2`,参考图由后端作为 multipart `image` part 传入编辑接口
- 每次新建关卡生成或重新生成关卡图都必须由 `api-server` 串起当前关卡资产包:AI 重绘开启时第一段沿用草稿生成第一关的拼图主图提示词配置和模型 / 尺寸 / 参考图规则生成 `coverImageSrc/coverAssetId` 作为关卡拼图画面和结果页预览图,提示词来源同样按显式画面描述、关卡画面描述、草稿摘要顺序回退,且固定要求输出画面比例为 `1:1`;上传图且关闭 AI 重绘时跳过这一段,把上传图或历史图持久化为 `sourceType=uploaded` 的正式候选。随后用正式候选图作为参考,`9:16` 生成完整拼图游戏关卡画面并写入 `levelSceneImageSrc/levelSceneImageObjectKey`,提示词必须要求道具按钮上不要显示次数标注,且返回按钮和设置按钮旁禁止标注文字;UI spritesheet 与关卡纯背景在关卡画面完成后并发生成,spritesheet 用 `1:1``1k` 先生成纯绿色绿幕背景图,后端上传 OSS 前必须把绿幕扣成透明 PNG,再写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,按钮顺序固定为返回、设置、下一关、提示、原图、冻结,按钮素材自身保留对应中文文字,返回和设置按钮不得额外生成白色外圈、白底圆环或浮雕外框;纯背景用 `9:16``1k` 写入 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,提示词必须包含“禁止在背景中出现人像或和拼图画面中主体一致的内容”。运行态不直接使用第二段完整关卡画面,但必须持久化它用于追踪和后续再生成。结果页局部关卡生成进度按 AI 重绘开启约 270 秒、关闭 AI 重绘约 180 秒展示
- 结果页允许多关卡并行编辑和生成;某一关卡图片生成完成回包只静默更新该关卡素材与生成态,不得自动打开或切换关卡详情面板,避免打断用户正在编辑的其它关卡。
- 结果页 UI 背景重生成只禁用 UI 背景自己的按钮和确认动作,不禁用“新增关卡”、关卡图片生成、关卡详情编辑和结果页导航;关卡图片生成也只标记对应关卡的局部生成进度
- 结果页关卡图片生成只标记对应关卡的局部生成进度,不禁用“新增关卡”、其它关卡详情编辑和结果页导航。
- 结果页单关测试只能把完整草稿持久化,并通过 `levelId` 指定运行态起始关卡;不得把单关快照作为整份草稿调用 `updatePuzzleWork`,否则 source session 和作品 profile 的 `levels` 会被覆盖成单关,退出重进后其它关卡会丢失。
- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。
- 拼图 UI 背景是作品运行态背景,不只属于第一关;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺 `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承同作品首个可用 UI 背景,仍缺失时才沿用当前运行态快照背景或默认 UI。
- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回和设置按钮的点击容器只提供透明点击区,不再叠加默认白色圆形底;底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
- 拼图运行态壳层自身要补齐 `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,不能依赖外层平台壳来提供主题变量;`/puzzle` 直达页和平台内嵌页都必须渲染同一套主题语义类。
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`
- 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。
@@ -94,9 +99,10 @@
入口表单只展示:
- 题材主题。
- `2D素材风格` 横向风格卡:扁平图标、赛璐璐卡通、像素复古、手绘水彩、贴纸描边、厚涂图标、自定义。
- 难度:轻松、标准、进阶、硬核。
入口不再要求用户选择素材风格;历史草稿和旧接口中的 `assetStyleId` / `assetStyleLabel` / `assetStylePrompt` 仅作为兼容字段保留,新入口提交不再写入这些字段。
难度映射:
| 难度 | clearCount | difficulty | 总物品数 | 物品种类 |
@@ -104,29 +110,27 @@
| 轻松 | 8 | 2 | 24 | 3 |
| 标准 | 12 | 4 | 36 | 9 |
| 进阶 | 16 | 6 | 48 | 15 |
| 硬核 | 21 | 8 | 63 | 21 |
| 硬核 | 21 | 8 | 63 | 20 |
当前素材生成流水线:
1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。
2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;作品摘要在素材或背景未完整时下发 `generationStatus=generating`素材和背景完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`
3. 物品素材不再调用 Hyper3D Rodin,不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材
4. 物品 sheet 走 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,单张 `1:1` 图固定 `5*5`,每张承载 `5` 物品、每个物品 `5` 个视角
5. 物品 sheet prompt、切图、透明化和五视角切片持久化复用 `generated_asset_sheets` 通用模块;Match3D 只传入题材 / 风格 subject、物品行 prompt 模板和“同一行五格必须是同一物品五个不同视角”的特殊设定,并把通用切片结果映射回 `generatedItemAssets[].imageViews[]`
6. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并对贴透明背景的弱绿 / 暗绿轮廓像素做去绿污染处理,最后按剩余可见主体二次收紧;不要先裁剪单格再各自去绿
7. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角
2. 先写入可恢复草稿 profile,再执行文本计划、关卡整图生成、三张派生图生成、OSS 上传和素材解析;作品摘要在背景、UI spritesheet 或物品 spritesheet 未完整时下发 `generationStatus=generating`,完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`
3. 首次调用 VectorEngine `gpt-image-2`,无参考图,竖屏 `9:16`,生成完整抓大鹅关卡画面并持久化到 `generatedBackgroundAsset.levelSceneImageSrc/levelSceneImageObjectKey`。提示词必须包含用户主题描述、顶部返回 / 标题倒计时 / 设置按钮、中间与主题匹配且贴横向边缘的容器,以及底部“移出 / 凑齐 / 打乱”三个道具按钮
4. 关卡整图完成后并发发起三次 `gpt-image-2` 编辑请求,三者都以关卡整图作为参考图:`1K``1:1` 的 UI spritesheet 写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey``1K``9:16` 的背景图写入 `imageSrc/imageObjectKey``2K``1:1` 物品 spritesheet 写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`
5. UI spritesheet 提示词固定要求按从上到下、从左到右整理纯绿色绿幕背景素材:返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮;后端上传 OSS 前必须把绿幕扣成透明 PNG。背景图提示词固定要求移除全部 UI 组件和容器内含物,完整保留容器和背景,并补全被 UI 覆盖的背景内容
6. 物品 spritesheet 固定 `10行*10列`、统一纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;素材间距严格均匀分布,每一行包含两种物品,每种物品五个不同形态,物品来自参考图中心容器中的 2D 素材,严禁高相似度物品。新流程每次固定解析并持久化 `20` 种物品,物品信息列表全部展示这 `20` 种;持久化单格映射必须按 `row = itemIndex / 2 + 1``col = itemIndex % 2 * 5 + viewIndex + 1` 写入通用系列素材图集,不能再用 `row = itemIndex + 1``generatedItemAssets[].imageViews[]` 仍兼容已切好的五视角图,缺失时运行态和编辑器按 spritesheet 自动解析结果回退
7. 前端和运行态统一使用 alpha 连通域矩形检测解析 spritesheet:UI 图按返回、设置、方格、移出、凑齐、打乱顺序映射回原 UI 位置;物品图按检测顺序每 `5` 个区域组成一个物品的五个形态,最多 `20` 个物品。透明背景是解析前提,不能在前端按固定像素坐标写死切片
8. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大``中``小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。
9. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。该容器参考图由后端内嵌到 `api-server`,不要依赖运行时当前目录下的 `public/` 文件
10. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递
11. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 容器图。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景 / 默认容器。
9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递
10. 背景、UI spritesheet、物品 spritesheet 和历史容器兼容字段的持久化真相仍在 `generatedItemAssets[].backgroundAsset` 与提升后的 `generatedBackgroundAsset`Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 图集。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景
结果页当前结构:
- `作品信息`:名称、描述、标签;封面编辑收口到发布面板。
- `难度配置`:四档离散拖动条,显示需要消除、总物品数、物品种类、已生成物品种类。
- `素材配置 > 物品`:两列素材卡,点击打开独立五视角预览面板;支持删除、批量新增和批量重新生成。替换模式必须保留原 `itemId` 和列表顺序。
- `素材配置 > UI`背景图与运行态 UI 预览,重生成消耗 `2` 泥点;UI 预览必须复用运行态顶部 HUD、中央容器棋盘、容器图定位和底部槽位样式,不单独维护一套简化预览 UI。
- `素材配置 > 容器形象`:单独预览和重生成中心容器,消耗 `2` 泥点。
- `素材配置 > 物品`:两列素材卡固定展示 20 个物品,点击打开独立五视角预览面板;支持删除、批量新增和批量重新生成。替换模式必须保留原 `itemId` 和列表顺序。
- `素材配置 > UI素材`预览背景图、UI spritesheet 原图、物品 spritesheet 原图和物品 spritesheet 自动解析缩略图;背景图只支持预览,不提供重新生成入口。UI 预览必须复用运行态顶部 HUD、中央容器棋盘和底部槽位样式,不单独维护一套简化预览 UI。
运行态当前口径:
@@ -136,8 +140,9 @@
- 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。
- 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。
- 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`
- 局内容器图在移动端宽度大于屏幕宽度并略向下压,当前运行态使用 `w-[min(116vw,42rem)]``top-[54%]` 放大和下移容器图本体,保持原图比例不拉伸且不改变后端物品布局、点击半径或消除规则;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底
- 难度只决定本局加载的物品种类数量:轻松 3、标准 9、进阶 15、硬核 20。硬核仍保留 21 次消除和 63 件总物品,运行态按 20 种素材循环复用,不要求生成第 21 种素材
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景、UI spritesheet 和物品 spritesheet;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺图集字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和图集字段传给 `Match3DRuntimeShell`
- 背景图作为运行态全屏背景,图内已经保留容器;旧 `containerImage*` 只作为历史透明容器兼容字段。若 `containerImage*``uiSpritesheetImage*` 同源,运行态不得把 UI spritesheet 当中心容器图叠到棋盘上。
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
- `itemSize` 只缩放生成 2D 图片本体:`大``中``小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中``小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。
- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -19,8 +19,17 @@ export type Match3DGeneratedItemSize = '大' | '中' | '小' | string;
export interface Match3DGeneratedBackgroundAsset {
prompt: string;
levelScenePrompt?: string | null;
levelSceneImageSrc?: string | null;
levelSceneImageObjectKey?: string | null;
imageSrc?: string | null;
imageObjectKey?: string | null;
uiSpritesheetPrompt?: string | null;
uiSpritesheetImageSrc?: string | null;
uiSpritesheetImageObjectKey?: string | null;
itemSpritesheetPrompt?: string | null;
itemSpritesheetImageSrc?: string | null;
itemSpritesheetImageObjectKey?: string | null;
containerPrompt?: string | null;
containerImageSrc?: string | null;
containerImageObjectKey?: string | null;
@@ -51,6 +51,12 @@ export interface PuzzleDraftLevel {
uiBackgroundPrompt?: string | null;
uiBackgroundImageSrc?: string | null;
uiBackgroundImageObjectKey?: string | null;
levelSceneImageSrc?: string | null;
levelSceneImageObjectKey?: string | null;
uiSpritesheetImageSrc?: string | null;
uiSpritesheetImageObjectKey?: string | null;
levelBackgroundImageSrc?: string | null;
levelBackgroundImageObjectKey?: string | null;
backgroundMusic?: CreationAudioAsset | null;
candidates: PuzzleGeneratedImageCandidate[];
selectedCandidateId: string | null;
@@ -59,6 +59,10 @@ export interface PuzzleRuntimeLevelSnapshot {
coverImageSrc: string | null;
uiBackgroundImageSrc?: string | null;
uiBackgroundImageObjectKey?: string | null;
levelBackgroundImageSrc?: string | null;
levelBackgroundImageObjectKey?: string | null;
uiSpritesheetImageSrc?: string | null;
uiSpritesheetImageObjectKey?: string | null;
backgroundMusic?: CreationAudioAsset | null;
board: PuzzleBoardSnapshot;
status: PuzzleRuntimeLevelStatus;
+2 -2
View File
@@ -1,4 +1,4 @@
import { Buffer } from 'node:buffer';
import { Buffer } from 'node:buffer';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
@@ -101,7 +101,7 @@ const onlyIds = process.argv
.filter(Boolean);
const templates = rawTemplates.filter((template) => !onlyIds.length || onlyIds.includes(template.id));
const dryRun = args.has('--dry-run') || !args.has('--live');
const requests = templates.map((template) => ({ id: template.id, title: template.title, body: { model: 'gpt-image-2-all', prompt: template.prompt, n: 1, size: '1024x1024' } }));
const requests = templates.map((template) => ({ id: template.id, title: template.title, body: { model: 'gpt-image-2', prompt: template.prompt, n: 1, size: '1024x1024' } }));
if (dryRun) {
console.log(JSON.stringify({ mode: 'dry-run', outDir, count: requests.length, requests }, null, 2));
process.exit(0);
+81 -23
View File
@@ -772,6 +772,12 @@ function buildVectorEngineImagesGenerationUrl(baseUrl) {
: `${baseUrl}/v1/images/generations`;
}
function buildVectorEngineImagesEditUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/edits`
: `${baseUrl}/v1/images/edits`;
}
function collectStringsByKey(value, targetKey, output) {
if (Array.isArray(value)) {
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
@@ -828,28 +834,41 @@ function inferExtensionFromBytes(bytes, preferredPath) {
return path.extname(preferredPath).replace(/^\./u, '') || 'png';
}
function toDataUrl(filePath) {
function mimeFromExtension(extension) {
if (extension === 'jpg' || extension === 'jpeg') {
return 'image/jpeg';
}
if (extension === 'webp') {
return 'image/webp';
}
return 'image/png';
}
function readReferenceImage(filePath) {
if (!existsSync(filePath)) {
return null;
}
const bytes = readFileSync(filePath);
const extension = inferExtensionFromBytes(bytes, filePath);
const mime = extension === 'jpg' ? 'image/jpeg' : `image/${extension}`;
return `data:${mime};base64,${bytes.toString('base64')}`;
return {
fileName: path.basename(filePath).replace(/"/gu, '_'),
mimeType: mimeFromExtension(extension),
bytes,
};
}
function pushReferenceImage(body, filePath) {
const reference = toDataUrl(filePath);
const reference = readReferenceImage(filePath);
if (!reference) {
return false;
}
body.image = [...(body.image || []), reference];
body.referenceImages = [...(body.referenceImages || []), reference];
return true;
}
function buildRequestBody(asset, size) {
const body = {
model: 'gpt-image-2-all',
model: 'gpt-image-2',
prompt: asset.prompt,
n: 1,
size: size || asset.size,
@@ -1624,18 +1643,49 @@ async function generateAsset(asset, env, size, force) {
};
}
const requestBody = buildRequestBody(asset, size);
const { referenceImages = [], ...requestBody } = buildRequestBody(asset, size);
const hasReferenceImages = referenceImages.length > 0;
const requestOptions = hasReferenceImages
? (() => {
const formData = new FormData();
formData.set('model', requestBody.model);
formData.set('prompt', requestBody.prompt);
formData.set('n', String(requestBody.n));
formData.set('size', requestBody.size);
for (const referenceImage of referenceImages) {
formData.append(
'image',
new Blob([referenceImage.bytes], { type: referenceImage.mimeType }),
referenceImage.fileName,
);
}
return {
url: buildVectorEngineImagesEditUrl(env.baseUrl),
options: {
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
},
body: formData,
},
};
})()
: {
url: buildVectorEngineImagesGenerationUrl(env.baseUrl),
options: {
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
};
const payloadText = await fetchWithTimeout(
buildVectorEngineImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
requestOptions.url,
requestOptions.options,
env.timeoutMs,
);
@@ -1687,7 +1737,7 @@ async function generateAsset(asset, env, size, force) {
size: requestBody.size,
extension: actualExtension,
source: urls[0] ? 'url' : 'b64_json',
usedReferenceImage: Boolean(requestBody.image),
usedReferenceImage: hasReferenceImages,
};
}
@@ -1715,19 +1765,27 @@ function dryRun(selectedAssets, size) {
{
mode: 'dry-run',
assets: selectedAssets.map((asset) => {
const body = buildRequestBody(asset, size);
const { referenceImages = [], ...body } = buildRequestBody(asset, size);
return {
id: asset.id,
endpoint: referenceImages.length
? '/v1/images/edits'
: '/v1/images/generations',
outputPath: outputPathFor(asset),
sourceOutputPath: asset.transparent
? sourceOutputPathFor(asset)
: undefined,
transparent: asset.transparent,
localPostprocess: asset.localPostprocess,
body: {
...body,
image: body.image ? ['<local style reference image>'] : undefined,
},
body: referenceImages.length ? undefined : body,
form: referenceImages.length
? {
...body,
imageParts: referenceImages.map(
(referenceImage) => referenceImage.fileName,
),
}
: undefined,
};
}),
},
@@ -1,4 +1,4 @@
import { Buffer } from 'node:buffer';
import { Buffer } from 'node:buffer';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -215,7 +215,7 @@ async function downloadImage(url, timeoutMs) {
async function generateOne(env, template, outDir, size) {
const requestBody = {
model: 'gpt-image-2-all',
model: 'gpt-image-2',
prompt: buildPrompt(template),
n: 1,
size,
@@ -278,7 +278,7 @@ if (dryRun) {
id: template.id,
title: template.title,
body: {
model: 'gpt-image-2-all',
model: 'gpt-image-2',
prompt: buildPrompt(template),
n: 1,
size,
+102 -32
View File
@@ -817,12 +817,18 @@ function resolveEnv() {
};
}
function buildUrl(baseUrl) {
function buildGenerationUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/generations`
: `${baseUrl}/v1/images/generations`;
}
function buildEditUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/edits`
: `${baseUrl}/v1/images/edits`;
}
function hasHeader(headers, targetName) {
return Object.keys(headers).some(
(name) => name.toLowerCase() === targetName.toLowerCase(),
@@ -954,7 +960,7 @@ function inferExtensionFromBytes(bytes) {
return 'png';
}
function imagePathToDataUrl(imagePath) {
function imagePathToReferenceImage(imagePath) {
if (!existsSync(imagePath)) {
throw new Error(`Reference image not found: ${imagePath}`);
}
@@ -967,7 +973,44 @@ function imagePathToDataUrl(imagePath) {
: extension === '.webp'
? 'image/webp'
: 'image/png';
return `data:${mimeType};base64,${bytes.toString('base64')}`;
return {
fieldName: 'image',
fileName: path.basename(imagePath).replace(/"/gu, '_'),
mimeType,
bytes,
};
}
function buildMultipartBody(fields, files) {
const boundary = `----genarrative-${Date.now().toString(16)}-${Math.random()
.toString(16)
.slice(2)}`;
const chunks = [];
const push = (value) => {
chunks.push(Buffer.isBuffer(value) ? value : Buffer.from(value));
};
for (const [name, value] of Object.entries(fields)) {
push(`--${boundary}\r\n`);
push(`Content-Disposition: form-data; name="${name}"\r\n\r\n`);
push(`${value}\r\n`);
}
for (const file of files) {
push(`--${boundary}\r\n`);
push(
`Content-Disposition: form-data; name="${file.fieldName}"; filename="${file.fileName}"\r\n`,
);
push(`Content-Type: ${file.mimeType}\r\n\r\n`);
push(file.bytes);
push('\r\n');
}
push(`--${boundary}--\r\n`);
return {
body: Buffer.concat(chunks),
contentType: `multipart/form-data; boundary=${boundary}`,
};
}
async function fetchJson(url, options, timeoutMs) {
@@ -1011,27 +1054,45 @@ async function downloadUrl(url, timeoutMs) {
async function generateConcept(env, concept) {
const requestBody = {
model: 'gpt-image-2-all',
model: 'gpt-image-2',
prompt: concept.prompt,
n: 1,
size: '1024x1024',
};
if (concept.referenceImages?.length) {
requestBody.image = concept.referenceImages.map(imagePathToDataUrl);
}
const payload = await fetchJson(
buildUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
const referenceImages = (concept.referenceImages || []).map(
imagePathToReferenceImage,
);
const payload = referenceImages.length
? await (async () => {
const multipart = buildMultipartBody(requestBody, referenceImages);
return fetchJson(
buildEditUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': multipart.contentType,
},
body: multipart.body,
},
env.timeoutMs,
);
})()
: await fetchJson(
buildGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const urls = extractImageUrls(payload);
const b64Images = extractBase64Images(payload);
@@ -1072,19 +1133,28 @@ if (dryRun) {
requests: selected.map((concept) => ({
id: concept.id,
title: concept.title,
body: {
model: 'gpt-image-2-all',
prompt: concept.prompt,
n: 1,
size: '1024x1024',
...(concept.referenceImages?.length
? {
image: concept.referenceImages.map((imagePath) =>
path.relative(repoRoot, imagePath),
),
}
: {}),
},
endpoint: concept.referenceImages?.length
? '/v1/images/edits'
: '/v1/images/generations',
body: concept.referenceImages?.length
? undefined
: {
model: 'gpt-image-2',
prompt: concept.prompt,
n: 1,
size: '1024x1024',
},
form: concept.referenceImages?.length
? {
model: 'gpt-image-2',
prompt: concept.prompt,
n: 1,
size: '1024x1024',
imageParts: concept.referenceImages.map((imagePath) =>
path.relative(repoRoot, imagePath),
),
}
: undefined,
})),
},
null,
@@ -197,6 +197,86 @@ pub(crate) fn slice_generated_asset_sheet(
Ok(slices)
}
pub(crate) fn slice_generated_asset_sheet_two_items_per_row(
image: &DownloadedOpenAiImage,
item_names: &[String],
grid_size: usize,
views_per_item: usize,
) -> Result<Vec<Vec<GeneratedAssetSheetSliceImage>>, AppError> {
if grid_size == 0 || views_per_item == 0 {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": "系列素材图集的 n 和每物品视图数必须大于 0。",
})),
);
}
if !grid_size.is_multiple_of(views_per_item) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": "系列素材图集每行必须能均分为若干物品。",
"gridSize": grid_size,
"viewsPerItem": views_per_item,
})),
);
}
let grid_size_u32 = u32::try_from(grid_size).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": "系列素材图集的 n 超出可支持范围。",
}))
})?;
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": format!("系列素材图集解码失败:{error}"),
}))
})?;
let source = apply_generated_asset_sheet_green_screen_alpha(source);
let (width, height) = source.dimensions();
if width / grid_size_u32 == 0 || height / grid_size_u32 == 0 {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": "系列素材图集尺寸过小,无法切割。",
})),
);
}
let items_per_row = grid_size / views_per_item;
let max_item_count = grid_size.saturating_mul(items_per_row);
let mut slices = Vec::with_capacity(item_names.len().min(max_item_count));
for item_index in 0..item_names.len().min(max_item_count) {
let row = (item_index / items_per_row) as u32;
let start_col = ((item_index % items_per_row) * views_per_item) as u32;
let mut views = Vec::with_capacity(views_per_item);
for view_offset in 0..views_per_item {
let col = start_col + view_offset as u32;
let (crop_x, crop_y, crop_width, crop_height) =
resolve_generated_asset_sheet_cell_crop(&source, grid_size_u32, row, col);
let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height);
let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
let mut cursor = std::io::Cursor::new(Vec::new());
cleaned
.write_to(&mut cursor, ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": format!("系列素材图集切割失败:{error}"),
}))
})?;
views.push(GeneratedAssetSheetSliceImage {
bytes: cursor.into_inner(),
});
}
slices.push(views);
}
Ok(slices)
}
pub(crate) fn crop_generated_asset_sheet_view_edge_matte(
image: image::DynamicImage,
) -> image::DynamicImage {
@@ -958,7 +1038,7 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
))
}
fn apply_generated_asset_sheet_green_screen_alpha(
pub(crate) fn apply_generated_asset_sheet_green_screen_alpha(
source: image::DynamicImage,
) -> image::DynamicImage {
let mut image = source.to_rgba8();
+35 -6
View File
@@ -71,10 +71,12 @@ use crate::{
},
auth::AuthenticatedAccessToken,
config::AppConfig,
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, OpenAiGeneratedImages, OpenAiReferenceImage,
build_openai_image_http_client, create_openai_image_edit, create_openai_image_generation,
build_openai_image_http_client, create_openai_image_edit,
create_openai_image_edit_with_references, create_openai_image_generation,
require_openai_image_settings,
},
platform_errors::map_oss_error,
@@ -95,10 +97,10 @@ const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4;
const MATCH3D_DRAFT_GENERATION_POINTS_COST: u64 = 10;
const MATCH3D_BACKGROUND_IMAGE_POINTS_COST: u64 = 2;
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH: u64 = 2;
const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 5;
const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 20;
const MATCH3D_ITEM_VIEW_COUNT: usize = 5;
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 5;
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 25;
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 10;
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 20;
const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL: &str = "gemini-3-pro-image-preview";
const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO: &str = "1:1";
const MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000;
@@ -118,7 +120,7 @@ const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作
const MATCH3D_CLICK_SOUND_ASSET_KIND: &str = "match3d_click_sound";
const MATCH3D_PIXEL_RETRO_STYLE_PROMPT: &str = "真正复古像素 2D 游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。";
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DConfigJson {
theme_text: String,
@@ -170,15 +172,33 @@ struct Match3DGeneratedItemImageView {
image_object_key: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DGeneratedBackgroundAsset {
prompt: String,
#[serde(default)]
level_scene_prompt: Option<String>,
#[serde(default)]
level_scene_image_src: Option<String>,
#[serde(default)]
level_scene_image_object_key: Option<String>,
#[serde(default)]
image_src: Option<String>,
#[serde(default)]
image_object_key: Option<String>,
#[serde(default)]
ui_spritesheet_prompt: Option<String>,
#[serde(default)]
ui_spritesheet_image_src: Option<String>,
#[serde(default)]
ui_spritesheet_image_object_key: Option<String>,
#[serde(default)]
item_spritesheet_prompt: Option<String>,
#[serde(default)]
item_spritesheet_image_src: Option<String>,
#[serde(default)]
item_spritesheet_image_object_key: Option<String>,
#[serde(default)]
container_prompt: Option<String>,
#[serde(default)]
container_image_src: Option<String>,
@@ -445,8 +465,17 @@ impl From<shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse>
.background_asset
.map(|asset| Match3DGeneratedBackgroundAsset {
prompt: asset.prompt,
level_scene_prompt: asset.level_scene_prompt,
level_scene_image_src: asset.level_scene_image_src,
level_scene_image_object_key: asset.level_scene_image_object_key,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
ui_spritesheet_prompt: asset.ui_spritesheet_prompt,
ui_spritesheet_image_src: asset.ui_spritesheet_image_src,
ui_spritesheet_image_object_key: asset.ui_spritesheet_image_object_key,
item_spritesheet_prompt: asset.item_spritesheet_prompt,
item_spritesheet_image_src: asset.item_spritesheet_image_src,
item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
@@ -229,12 +229,27 @@ pub(super) async fn compile_match3d_draft_for_session(
)
.await?;
let existing_assets = get_match3d_existing_generated_item_assets(
let mut existing_assets = get_match3d_existing_generated_item_assets(
state,
owner_user_id.as_str(),
profile_id.as_str(),
)
.await;
let generated_background_asset = resolve_or_generate_match3d_level_asset_bundle(
state,
request_context,
owner_user_id.as_str(),
session.session_id.as_str(),
profile_id.as_str(),
&config,
generated_work_metadata.background_prompt.as_str(),
&existing_assets,
)
.await?;
attach_match3d_background_asset_to_assets(
&mut existing_assets,
generated_background_asset.clone(),
);
let generated_item_assets = generate_match3d_item_assets(
state,
request_context,
@@ -245,18 +260,22 @@ pub(super) async fn compile_match3d_draft_for_session(
&config,
generated_work_metadata.items,
existing_assets,
Some(generated_background_asset.clone()),
)
.await?;
let generated_item_assets = ensure_match3d_background_asset(
let mut generated_item_assets = generated_item_assets;
attach_match3d_background_asset_to_assets(
&mut generated_item_assets,
generated_background_asset,
);
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
owner_user_id.as_str(),
session.session_id.as_str(),
owner_user_id.as_str(),
profile_id.as_str(),
&config,
generated_work_metadata.background_prompt.as_str(),
generated_item_assets,
&generated_item_assets,
)
.await?;
let existing_cover_image_src = get_match3d_existing_cover_image_src(
@@ -3,9 +3,8 @@ use super::*;
use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte;
use crate::generated_asset_sheets::{
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage,
build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes,
slice_generated_asset_sheet,
GeneratedAssetSheetSliceImage, persist_generated_asset_sheet_bytes,
slice_generated_asset_sheet_two_items_per_row,
};
pub(super) async fn generate_match3d_item_assets(
@@ -18,6 +17,7 @@ pub(super) async fn generate_match3d_item_assets(
config: &Match3DConfigJson,
item_plan: Vec<Match3DGeneratedItemPlan>,
existing_assets: Vec<Match3DGeneratedItemAsset>,
generated_background_asset: Option<Match3DGeneratedBackgroundAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
// 中文注释:抓大鹅音频生成当前关闭;自动草稿只补齐 2D 物品图片和可选点击音效。
let target_item_count = resolve_match3d_generated_item_count(config);
@@ -37,6 +37,7 @@ pub(super) async fn generate_match3d_item_assets(
config,
item_plan,
assets,
generated_background_asset,
)
.await?;
}
@@ -76,6 +77,7 @@ async fn ensure_match3d_item_image_assets(
config: &Match3DConfigJson,
item_plan: Vec<Match3DGeneratedItemPlan>,
existing_assets: Vec<Match3DGeneratedItemAsset>,
generated_background_asset: Option<Match3DGeneratedBackgroundAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets);
let target_item_count = resolve_match3d_generated_item_count(config);
@@ -101,9 +103,11 @@ async fn ensure_match3d_item_image_assets(
background_music_style: None,
background_music_prompt: None,
background_asset: if index == 0 {
assets
.first()
.and_then(|asset| asset.background_asset.clone())
generated_background_asset.clone().or_else(|| {
assets
.first()
.and_then(|asset| asset.background_asset.clone())
})
} else {
None
},
@@ -160,6 +164,8 @@ struct Match3DItemImageGenerationSeed {
struct Match3DMaterialBatchOutput {
task_id: String,
prompt: String,
image_src: Option<String>,
image_object_key: Option<String>,
generated_at_micros: i64,
items: Vec<(
Match3DItemImageGenerationSeed,
@@ -194,12 +200,17 @@ async fn generate_match3d_item_image_assets_in_batches(
.map(|chunk| {
let chunk_seeds = chunk.to_vec();
async move {
let item_names = chunk_seeds
.iter()
.map(|item| item.item_name.clone())
.collect::<Vec<_>>();
let material_sheet =
generate_match3d_material_sheet(state, config, &item_names).await?;
let material_sheet = generate_match3d_material_sheet_from_level_scene(
state,
owner_user_id,
session_id,
profile_id,
config,
chunk_seeds
.iter()
.find_map(|seed| seed.background_asset.as_ref()),
)
.await?;
let generated_at_micros = current_utc_micros();
let persisted_seed_count = chunk_seeds
.iter()
@@ -218,14 +229,17 @@ async fn generate_match3d_item_image_assets_in_batches(
.iter()
.map(|item| item.item_name.clone())
.collect::<Vec<_>>();
let item_images = slice_generated_asset_sheet(
let item_images = slice_generated_asset_sheet_two_items_per_row(
&material_sheet.image,
&persisted_item_names,
MATCH3D_MATERIAL_GRID_SIZE as usize,
MATCH3D_ITEM_VIEW_COUNT,
)?;
Ok::<_, AppError>(Match3DMaterialBatchOutput {
task_id: material_sheet.task_id,
prompt: material_sheet.prompt,
image_src: material_sheet.image_src,
image_object_key: material_sheet.image_object_key,
generated_at_micros,
items: persisted_seeds
.into_iter()
@@ -248,14 +262,22 @@ async fn generate_match3d_item_image_assets_in_batches(
for batch in batches {
let sheet_task_id = batch.task_id;
let sheet_prompt = batch.prompt;
let sheet_image_src = batch.image_src;
let sheet_image_object_key = batch.image_object_key;
let generated_at_micros = batch.generated_at_micros;
for (item_index, (seed, item_images)) in batch.items.into_iter().enumerate() {
let item_slug = build_match3d_item_slug(seed.item_id.as_str(), seed.item_name.as_str());
let mut image_views = Vec::with_capacity(item_images.len());
for (view_index, item_image) in item_images.into_iter().enumerate() {
let view_number = view_index + 1;
let item_name_prompt =
format!("{}行:{} 的 5 个不同视角", item_index + 1, seed.item_name);
let (sheet_row_index, sheet_col_index) =
resolve_match3d_material_sheet_cell_indices(item_index, view_index);
let item_name_prompt = format!(
"{}行第{}种:{} 的 5 个不同形态",
item_index / 2 + 1,
item_index % 2 + 1,
seed.item_name
);
let view_upload = persist_generated_asset_sheet_bytes(
state,
GeneratedAssetSheetPersistInput {
@@ -277,8 +299,8 @@ async fn generate_match3d_item_image_assets_in_batches(
(item_index * MATCH3D_ITEM_VIEW_COUNT + view_index) as i64 + 1,
),
grid_size: MATCH3D_MATERIAL_GRID_SIZE as usize,
row_index: item_index + 1,
view_index: view_number,
row_index: sheet_row_index,
view_index: sheet_col_index,
prompt: GeneratedAssetSheetPersistPrompt {
sheet_prompt: Some(sheet_prompt.clone()),
item_name_prompt: Some(item_name_prompt),
@@ -322,7 +344,12 @@ async fn generate_match3d_item_image_assets_in_batches(
background_music_prompt: seed.background_music_prompt,
background_music: None,
click_sound: None,
background_asset: seed.background_asset,
background_asset: merge_match3d_item_spritesheet_asset_metadata(
seed.background_asset,
sheet_prompt.clone(),
sheet_image_src.clone(),
sheet_image_object_key.clone(),
),
status: "image_ready".to_string(),
error: None,
},
@@ -512,6 +539,7 @@ async fn append_match3d_new_item_assets(
return Ok(assets);
}
let mut next_item_index = next_match3d_generated_item_index(&assets);
let background_asset = find_match3d_generated_background_asset(&assets);
let item_seeds = append_plan
.padded_item_names
.into_iter()
@@ -527,7 +555,11 @@ async fn append_match3d_new_item_assets(
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_asset: None,
background_asset: if index == 0 {
background_asset.clone()
} else {
None
},
}
})
.collect::<Vec<_>>();
@@ -697,6 +729,8 @@ async fn replace_match3d_item_assets(
pub(super) struct Match3DMaterialSheet {
pub(super) task_id: String,
pub(super) prompt: String,
pub(super) image_src: Option<String>,
pub(super) image_object_key: Option<String>,
pub(super) image: DownloadedOpenAiImage,
}
@@ -710,6 +744,118 @@ pub(super) struct Match3DVectorEngineGeminiImageSettings {
pub(super) struct Match3DSlicedItemImage {
pub(super) bytes: Vec<u8>,
}
async fn generate_match3d_material_sheet_from_level_scene(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
) -> Result<Match3DMaterialSheet, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let prompt = build_match3d_item_spritesheet_prompt();
let reference = load_match3d_level_scene_reference_image(state, background_asset).await?;
let generated = create_openai_image_edit(
&http_client,
&settings,
prompt.as_str(),
Some(build_match3d_material_sheet_negative_prompt(config).as_str()),
"2k",
&reference,
"抓大鹅物品 spritesheet 生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅物品 spritesheet 生成失败:未返回图片",
}))
})?;
let image = make_match3d_spritesheet_image_transparent(image)?;
let upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["item-spritesheet", generated.task_id.as_str()],
"item-spritesheet.png",
image.mime_type.as_str(),
image.bytes.clone(),
"match3d_item_spritesheet_image",
Some(generated.task_id.as_str()),
current_utc_micros(),
)
.await?;
Ok(Match3DMaterialSheet {
task_id: generated.task_id,
prompt,
image_src: Some(upload.src),
image_object_key: Some(upload.object_key),
image,
})
}
fn merge_match3d_item_spritesheet_asset_metadata(
background_asset: Option<Match3DGeneratedBackgroundAsset>,
prompt: String,
image_src: Option<String>,
image_object_key: Option<String>,
) -> Option<Match3DGeneratedBackgroundAsset> {
background_asset.map(|mut asset| {
asset.item_spritesheet_prompt = Some(prompt);
asset.item_spritesheet_image_src = image_src;
asset.item_spritesheet_image_object_key = image_object_key;
asset
})
}
async fn load_match3d_level_scene_reference_image(
state: &AppState,
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
) -> Result<OpenAiReferenceImage, AppError> {
let Some(source) = background_asset
.and_then(|asset| {
asset
.level_scene_image_object_key
.as_deref()
.or(asset.level_scene_image_src.as_deref())
})
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": "抓大鹅物品 spritesheet 生成缺少关卡画面参考图",
})),
);
};
let bytes = if source.starts_with("data:image/") {
decode_match3d_data_url_bytes(source)?
} else if source.trim_start_matches('/').starts_with("generated-") {
read_match3d_generated_object_bytes(
state,
source,
"读取抓大鹅关卡画面参考图失败",
MATCH3D_ITEM_IMAGE_MAX_BYTES,
)
.await?
} else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": "抓大鹅关卡画面参考图必须是图片 Data URL 或 /generated-* 路径",
})),
);
};
Ok(OpenAiReferenceImage {
bytes,
mime_type: "image/png".to_string(),
file_name: "match3d-level-scene.png".to_string(),
})
}
pub(super) fn normalize_match3d_item_name(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”', '。', '', ',', '、'])
@@ -1115,20 +1261,20 @@ pub(super) fn resolve_match3d_gameplay_item_count(config: &Match3DConfigJson) ->
8 => 3,
12 => 9,
16 => 15,
20 | 21 => 21,
20 | 21 => 20,
_ => match config.difficulty {
0..=2 => 3,
3..=4 => 9,
5..=6 => 15,
_ => 21,
_ => 20,
},
}
.min(MATCH3D_MAX_GENERATED_ITEM_COUNT)
}
pub(super) fn resolve_match3d_generated_item_count(config: &Match3DConfigJson) -> usize {
round_match3d_item_count_to_full_sheet(resolve_match3d_gameplay_item_count(config))
.min(MATCH3D_MAX_GENERATED_ITEM_COUNT)
let _ = config;
MATCH3D_MAX_GENERATED_ITEM_COUNT
}
fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize {
@@ -1138,6 +1284,16 @@ fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize {
item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) * MATCH3D_MATERIAL_ITEM_BATCH_SIZE
}
pub(super) fn resolve_match3d_material_sheet_cell_indices(
item_index: usize,
view_index: usize,
) -> (usize, usize) {
let items_per_row = (MATCH3D_MATERIAL_GRID_SIZE as usize / MATCH3D_ITEM_VIEW_COUNT).max(1);
let row_index = item_index / items_per_row + 1;
let col_index = (item_index % items_per_row) * MATCH3D_ITEM_VIEW_COUNT + view_index + 1;
(row_index, col_index)
}
pub(super) fn sort_match3d_generated_assets(
mut assets: Vec<Match3DGeneratedItemAsset>,
) -> Vec<Match3DGeneratedItemAsset> {
@@ -1295,11 +1451,23 @@ pub(super) fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgrou
.filter(|value| !value.is_empty())
.is_some())
&& (asset
.container_image_object_key
.ui_spritesheet_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| asset
.ui_spritesheet_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| asset
.container_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| asset
.container_image_src
.as_deref()
@@ -1312,34 +1480,16 @@ pub(super) fn build_match3d_material_sheet_prompt(
config: &Match3DConfigJson,
item_names: &[String],
) -> String {
let asset_style_prompt = resolve_match3d_asset_style_prompt(config);
let style_clause = asset_style_prompt
.as_ref()
.map(|prompt| format!("整体画风遵循:{prompt}"))
.unwrap_or_default();
let subject_text = format!(
"{}题材的抓大鹅游戏2D物品素材。{style_clause}",
config.theme_text
);
let special_prompt = match3d_material_sheet_special_prompt();
build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
subject_text: subject_text.as_str(),
item_names,
grid_size: MATCH3D_MATERIAL_GRID_SIZE as usize,
item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视角"),
special_prompt: Some(special_prompt.as_str()),
})
.unwrap_or_else(|_| {
format!(
"生成一张1:1图片。固定生成5行*5列网格素材图,画面是{}题材的抓大鹅游戏2D物品素材。{}",
config.theme_text,
match3d_material_sheet_special_prompt(),
)
})
let _ = (config, item_names);
build_match3d_item_spritesheet_prompt()
}
pub(super) fn build_match3d_item_spritesheet_prompt() -> String {
"固定生成10行*10列spritesheet图,统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品".to_string()
}
fn match3d_material_sheet_special_prompt() -> String {
"一行五格必须是同一物品的五个不同视角,依次为正面、左前、右前、俯视、背面;".to_string()
"一行包含两种物品,每种物品的五个不同形态。".to_string()
}
pub(super) fn build_match3d_material_sheet_negative_prompt(config: &Match3DConfigJson) -> String {
@@ -1389,18 +1539,22 @@ pub(super) fn slice_match3d_material_sheet(
image: &DownloadedOpenAiImage,
item_names: &[String],
) -> Result<Vec<Vec<Match3DSlicedItemImage>>, AppError> {
slice_generated_asset_sheet(image, item_names, MATCH3D_MATERIAL_GRID_SIZE as usize).map(
|rows| {
rows.into_iter()
.map(|views| {
views
.into_iter()
.map(|view| Match3DSlicedItemImage { bytes: view.bytes })
.collect()
})
.collect()
},
slice_generated_asset_sheet_two_items_per_row(
image,
item_names,
MATCH3D_MATERIAL_GRID_SIZE as usize,
MATCH3D_ITEM_VIEW_COUNT,
)
.map(|rows| {
rows.into_iter()
.map(|views| {
views
.into_iter()
.map(|view| Match3DSlicedItemImage { bytes: view.bytes })
.collect()
})
.collect()
})
}
#[cfg(test)]
@@ -282,13 +282,25 @@ pub(super) fn map_match3d_image_view_from_work(
pub(super) fn map_match3d_background_asset_for_agent(
asset: Match3DGeneratedBackgroundAsset,
) -> shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
let ui_spritesheet_image_src = asset.ui_spritesheet_image_src.clone();
let ui_spritesheet_image_object_key = asset.ui_spritesheet_image_object_key.clone();
shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
prompt: asset.prompt,
level_scene_prompt: asset.level_scene_prompt,
level_scene_image_src: asset.level_scene_image_src,
level_scene_image_object_key: asset.level_scene_image_object_key,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
ui_spritesheet_prompt: asset.ui_spritesheet_prompt,
ui_spritesheet_image_src: ui_spritesheet_image_src.clone(),
ui_spritesheet_image_object_key: ui_spritesheet_image_object_key.clone(),
item_spritesheet_prompt: asset.item_spritesheet_prompt,
item_spritesheet_image_src: asset.item_spritesheet_image_src,
item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
container_image_src: ui_spritesheet_image_src.or(asset.container_image_src),
container_image_object_key: ui_spritesheet_image_object_key
.or(asset.container_image_object_key),
status: asset.status,
error: asset.error,
}
@@ -299,8 +311,17 @@ pub(super) fn map_match3d_background_asset_for_work(
) -> shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
prompt: asset.prompt,
level_scene_prompt: asset.level_scene_prompt,
level_scene_image_src: asset.level_scene_image_src,
level_scene_image_object_key: asset.level_scene_image_object_key,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
ui_spritesheet_prompt: asset.ui_spritesheet_prompt,
ui_spritesheet_image_src: asset.ui_spritesheet_image_src,
ui_spritesheet_image_object_key: asset.ui_spritesheet_image_object_key,
item_spritesheet_prompt: asset.item_spritesheet_prompt,
item_spritesheet_image_src: asset.item_spritesheet_image_src,
item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
@@ -327,6 +348,14 @@ pub(super) fn resolve_match3d_default_cover_image_src(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
asset
.ui_spritesheet_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.container_image_object_key
@@ -335,6 +364,14 @@ pub(super) fn resolve_match3d_default_cover_image_src(
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.ui_spritesheet_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.image_src
@@ -408,6 +445,10 @@ fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool {
fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool {
match3d_text_present(asset.image_src.as_ref())
|| match3d_text_present(asset.image_object_key.as_ref())
|| match3d_text_present(asset.ui_spritesheet_image_src.as_ref())
|| match3d_text_present(asset.ui_spritesheet_image_object_key.as_ref())
|| match3d_text_present(asset.item_spritesheet_image_src.as_ref())
|| match3d_text_present(asset.item_spritesheet_image_object_key.as_ref())
|| match3d_text_present(asset.container_image_src.as_ref())
|| match3d_text_present(asset.container_image_object_key.as_ref())
}
+165 -43
View File
@@ -147,17 +147,17 @@ fn match3d_item_image_path_segments_stay_unique_for_chinese_names() {
}
#[test]
fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
let width = 500;
let height = 500;
fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() {
let width = 1000;
let height = 1000;
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
let mut sheet = image::RgbaImage::new(width, height);
for row in 0..5 {
for col in 0..5 {
for row in 0..10 {
for col in 0..10 {
let color = image::Rgba([
32 + row as u8 * 40,
24 + col as u8 * 36,
210 - row as u8 * 30,
32 + row as u8 * 16,
24 + col as u8 * 18,
210 - row as u8 * 12,
255,
]);
for y in row * 100..(row + 1) * 100 {
@@ -180,9 +180,12 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice");
assert_eq!(slices.len(), 3);
for (row, views) in slices.iter().enumerate() {
for (item_index, views) in slices.iter().enumerate() {
let row = item_index / 2;
let start_col = (item_index % 2) * MATCH3D_ITEM_VIEW_COUNT;
assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT);
for (col, view) in views.iter().enumerate() {
for (view_index, view) in views.iter().enumerate() {
let col = start_col + view_index;
let decoded = image::load_from_memory(view.bytes.as_slice())
.expect("view should decode")
.to_rgba8();
@@ -190,12 +193,12 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
assert_eq!(
pixel.0,
[
32 + row as u8 * 40,
24 + col as u8 * 36,
210 - row as u8 * 30,
32 + row as u8 * 16,
24 + col as u8 * 18,
210 - row as u8 * 12,
255,
],
"row {row} col {col} should be cut from the fixed 5*5 grid row"
"item {item_index} view {view_index} should be cut from the fixed 10*10 grid"
);
}
}
@@ -203,8 +206,8 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
#[test]
fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() {
let width = 500;
let height = 500;
let width = 1000;
let height = 1000;
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255]));
for y in 1..5 {
@@ -616,6 +619,52 @@ fn match3d_background_image_postprocess_removes_transparent_pixels() {
);
}
#[test]
fn match3d_level_scene_prompt_uses_requested_theme_and_full_ui_layout() {
let prompt = build_match3d_level_scene_generation_prompt(&config("重庆火锅", 12, 4));
assert!(prompt.contains("重庆火锅"));
assert!(prompt.contains("第1关 重庆火锅"));
assert!(prompt.contains("返回按钮位于顶部左上角"));
assert!(prompt.contains("设置按钮"));
assert!(prompt.contains("和主题匹配的容器"));
assert!(prompt.contains("移出"));
assert!(prompt.contains("凑齐"));
assert!(prompt.contains("打乱"));
}
#[test]
fn match3d_derived_asset_prompts_match_three_sheet_pipeline() {
let config = config("水果", 12, 4);
let ui_prompt = build_match3d_ui_spritesheet_prompt();
let background_prompt = build_match3d_background_from_scene_prompt();
let item_prompt = build_match3d_material_sheet_prompt(
&config,
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
);
assert!(ui_prompt.contains("返回按钮"));
assert!(ui_prompt.contains("设置按钮"));
assert!(ui_prompt.contains("方格素材"));
assert!(ui_prompt.contains("纯绿色绿幕背景spritesheet"));
assert!(ui_prompt.contains("绿幕扣成透明"));
assert!(background_prompt.contains("移除画面中的所有UI组件"));
assert!(background_prompt.contains("完整保留容器和背景"));
assert!(item_prompt.contains("10行*10列"));
assert!(item_prompt.contains("纯绿色绿幕背景"));
assert!(item_prompt.contains("扣成透明"));
assert!(item_prompt.contains("每一行包含两种物品"));
assert!(item_prompt.contains("五个不同形态"));
}
#[test]
fn match3d_hardcore_generated_item_count_is_capped_by_ten_by_ten_sheet() {
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
20
);
}
#[test]
fn match3d_work_metadata_parses_gpt4o_json() {
let metadata = parse_match3d_work_metadata(
@@ -687,38 +736,69 @@ fn match3d_legacy_item_asset_without_size_defaults_to_large() {
}
#[test]
fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() {
fn match3d_draft_item_plan_rounds_up_to_full_ten_by_ten_sheet() {
let plan = parse_match3d_draft_plan(
r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#,
&config("水果", 12, 4),
)
.expect("draft plan should parse");
assert_eq!(plan.items.len(), 10);
assert_eq!(plan.items.len(), 20);
assert_eq!(plan.items[8].name, "蓝莓");
assert_ne!(plan.items[9].name, "蓝莓");
}
#[test]
fn match3d_generated_item_count_rounds_up_to_five_multiples() {
fn match3d_generated_item_count_uses_full_ten_by_ten_sheet_capacity() {
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 8, 2)),
5
20
);
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 12, 4)),
10
20
);
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 16, 6)),
15
20
);
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
25
20
);
}
#[test]
fn match3d_gameplay_item_count_uses_difficulty_loading_limit() {
assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 8, 2)),
3
);
assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 12, 4)),
9
);
assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 16, 6)),
15
);
assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 21, 8)),
20
);
}
#[test]
fn match3d_material_sheet_cell_indices_stay_inside_ten_by_ten_grid() {
let first = resolve_match3d_material_sheet_cell_indices(0, 0);
let second = resolve_match3d_material_sheet_cell_indices(1, 0);
let twentieth_last_view = resolve_match3d_material_sheet_cell_indices(19, 4);
assert_eq!(first, (1, 1));
assert_eq!(second, (1, 6));
assert_eq!(twentieth_last_view, (10, 10));
}
#[test]
fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() {
let assets = vec![test_match3d_generated_item_asset(1, "草莓")];
@@ -731,12 +811,11 @@ fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() {
}
#[test]
fn match3d_item_asset_points_cost_counts_five_item_batches() {
fn match3d_item_asset_points_cost_counts_ten_by_ten_sheet_batches() {
assert_eq!(calculate_match3d_item_assets_points_cost(0), 0);
assert_eq!(calculate_match3d_item_assets_points_cost(1), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(5), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(6), 4);
assert_eq!(calculate_match3d_item_assets_points_cost(10), 4);
assert_eq!(calculate_match3d_item_assets_points_cost(20), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(21), 4);
}
#[test]
@@ -775,7 +854,7 @@ fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() {
);
assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]);
assert_eq!(plan.padded_item_names.len(), 5);
assert_eq!(plan.padded_item_names.len(), 20);
assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]);
assert_eq!(
calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()),
@@ -872,6 +951,7 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
});
let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓");
generated_asset.image_src =
@@ -897,20 +977,19 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
}
#[test]
fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() {
fn match3d_material_sheet_prompt_requires_uniform_ten_by_ten_transparent_layout() {
let prompt = build_match3d_material_sheet_prompt(
&config("水果", 12, 4),
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
);
assert!(prompt.contains("5行*5列"));
assert!(prompt.contains("严格5*5均匀排布"));
assert!(prompt.contains("绿幕背景"));
assert!(prompt.contains("10行*10列spritesheet图"));
assert!(prompt.contains("纯绿色绿幕背景"));
assert!(prompt.contains("#00FF00"));
assert!(prompt.contains("单个素材格宽度的1/4空白间距"));
assert!(prompt.contains("约25%单格宽度"));
assert!(prompt.contains("禁止主体跨格"));
assert!(prompt.contains("贴边或越界"));
assert!(prompt.contains("素材间距严格均匀分布"));
assert!(prompt.contains("每一行包含两种物品"));
assert!(prompt.contains("每种物品的五个不同形态"));
assert!(prompt.contains("严禁出现两种高相似度的物品"));
}
#[test]
@@ -921,16 +1000,53 @@ fn match3d_material_sheet_prompt_hardens_pixel_retro_style() {
let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]);
let negative_prompt = build_match3d_material_sheet_negative_prompt(&config);
assert!(prompt.contains("64x64"));
assert!(prompt.contains("整数倍放大"));
assert!(prompt.contains("禁止抗锯齿"));
assert!(prompt.contains("真实 3D 渲染"));
assert!(prompt.contains("PBR 材质"));
assert!(prompt.contains("10行*10列spritesheet图"));
assert!(prompt.contains("纯绿色绿幕背景"));
assert!(negative_prompt.contains("抗锯齿"));
assert!(negative_prompt.contains("平滑插画"));
assert!(negative_prompt.contains("真实 3D 渲染"));
}
#[test]
fn match3d_spritesheet_green_screen_postprocess_turns_background_transparent() {
let width = 100;
let height = 100;
let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255]));
for y in 32..68 {
for x in 32..68 {
image.put_pixel(x, y, image::Rgba([220, 32, 48, 255]));
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image)
.write_to(&mut encoded, ImageFormat::Png)
.expect("spritesheet should encode");
let processed = make_match3d_spritesheet_image_transparent(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
.expect("spritesheet should postprocess");
let decoded = image::load_from_memory(processed.bytes.as_slice())
.expect("processed spritesheet should decode")
.to_rgba8();
assert_eq!(processed.mime_type, "image/png");
assert_eq!(processed.extension, "png");
assert_eq!(
decoded.get_pixel(0, 0).0[3],
0,
"绿幕背景必须在上传 OSS 前扣成透明 alpha"
);
assert_eq!(
decoded.get_pixel(width / 2, height / 2).0,
[220, 32, 48, 255],
"物品主体不能被绿幕去背误删"
);
}
#[test]
fn match3d_material_sheet_request_uses_vector_engine_gemini_contract() {
let body = build_match3d_vector_engine_gemini_image_request_body(
@@ -1060,6 +1176,7 @@ fn match3d_background_asset_requires_background_and_container_images() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
};
let with_container = Match3DGeneratedBackgroundAsset {
container_prompt: Some("果园容器".to_string()),
@@ -1106,6 +1223,7 @@ fn match3d_default_cover_prefers_generated_container_ui_image() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1181,8 +1299,8 @@ fn match3d_cover_reference_prompt_marks_reference_images() {
}
#[test]
fn match3d_cover_edit_prompt_preserves_uploaded_image() {
let prompt = build_match3d_cover_edit_prompt("水果封面");
fn match3d_cover_reference_generation_prompt_preserves_uploaded_image() {
let prompt = build_match3d_cover_uploaded_reference_prompt("水果封面");
assert!(prompt.contains("上传的封面图作为第一优先级"));
assert!(prompt.contains("保留主图的主体、构图、视角和主要配色"));
@@ -1225,6 +1343,7 @@ fn match3d_fallback_work_profile_keeps_generated_background_asset() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1362,6 +1481,7 @@ fn match3d_agent_session_response_hydrates_persisted_ui_assets() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1437,6 +1557,7 @@ fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydr
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1820,6 +1941,7 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
..test_match3d_generated_item_asset(1, "草莓")
}];

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