diff --git a/.codex/logs/run-dev-web-final.ps1 b/.codex/logs/run-dev-web-final.ps1 new file mode 100644 index 00000000..196bc9f0 --- /dev/null +++ b/.codex/logs/run-dev-web-final.ps1 @@ -0,0 +1,4 @@ +Set-Location 'C:\Genarrative' +$env:RUST_SERVER_TARGET = 'http://127.0.0.1:8082' +$env:GENARRATIVE_RUNTIME_SERVER_TARGET = 'http://127.0.0.1:8082' +npm.cmd run dev:web *> 'C:\Genarrative\.codex\logs\dev-web-final.out.log' diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs index 63e35176..1ba9eb69 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs @@ -8,7 +8,7 @@ const __dirname = path.dirname(__filename); const skillRoot = path.resolve(__dirname, '..'); const repoRoot = path.resolve(skillRoot, '..', '..', '..'); const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations'); -const defaultTimeoutMs = 180000; +const defaultTimeoutMs = 1000000; const prompts = [ { diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs index f3a69aed..72e05646 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs @@ -13,7 +13,7 @@ const promptsPath = path.join( 'puzzle-template-prompts.json', ); const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates'); -const defaultTimeoutMs = 180000; +const defaultTimeoutMs = 1000000; const args = new Map(); for (let index = 2; index < process.argv.length; index += 1) { diff --git a/.env.example b/.env.example index d2cfd6e6..03d8c2c1 100644 --- a/.env.example +++ b/.env.example @@ -127,6 +127,11 @@ APIMART_BASE_URL="https://api.apimart.ai/v1" APIMART_API_KEY="YOUR_APIMART_API_KEY" APIMART_IMAGE_REQUEST_TIMEOUT_MS="180000" +# VectorEngine GPT-image-2 / Gemini image generation config. +VECTOR_ENGINE_BASE_URL="https://api.vectorengine.ai" +VECTOR_ENGINE_API_KEY="" +VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS="1000000" + # 阿里云 OSS 配置。 # Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量, # 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。 diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 3d5493df..dde079e5 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,38 @@ --- +## 2026-05-14 创作页图像输入统一封装为图像组件 + +- 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。 +- 决策:通用图像输入 UI 统一使用 `src/components/common/CreativeImageInputPanel.tsx`。组件采用受控模式,只负责主图上传卡、画面描述输入、参考图缩略图与预览、AI 重绘开关、错误展示和提交按钮;外层页面负责文件读取/裁剪、历史素材弹层、计费确认、自动保存和具体后端请求。 +- 影响范围:拼图创作入口、后续抓大鹅封面生成入口、其它需要复用图像输入链路的创作页。 +- 验证方式:拼图入口交互测试继续覆盖四种路径;后续页面接入时只传入业务回调与文案,不复制上传卡和参考图缩略图实现。 +- 关联文档:`docs/technical/【前端体验】图像组件统一封装与复用边界-2026-05-14.md`。 + +## 2026-05-14 汪汪声浪创作入口改为创作 Tab 内嵌轻配置 + +- 背景:汪汪声浪入口最初走独立配置阶段,和拼图、抓大鹅的创作页内嵌结构不一致,用户在入口切换时会感觉像跳到了另一张页面。 +- 决策:`bark-battle` 的创作入口只在创作 Tab 内嵌渲染轻配置表单,入口点击只切到创作页并选中该模板,不再使用 `bark-battle-config` 独立阶段;runtime 退出时回到创作页并恢复汪汪声浪模板选中态。 +- 影响范围:`PlatformEntryFlowShellImpl`、`BarkBattleConfigEditor`、`BarkBattleRuntimeShell`、入口配置说明和相关交互测试。 +- 验证方式:创作 Tab 中点击汪汪声浪后直接看到内嵌表单,不应再出现单独配置页;发布进入 runtime 后退出应回到创作页的汪汪声浪模板。 +- 关联文档:`docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md`。 + +## 2026-05-14 拼图与抓大鹅生成页移动端收口为等待与计时双栏 + +- 背景:拼图与抓大鹅的草稿生成页在移动端同时展示“当前批次”“预计等待”“计时”时,模型执行视角过重,信息也显得散。 +- 决策:这两类轻量玩法的生成页隐藏“当前批次”模块,只保留“预计等待”和“计时”并排展示;生成步骤进入页面时按顺序从左侧滑入,强化推进感。 +- 影响范围:`CustomWorldGenerationView`、拼图与抓大鹅创作入口调用处、移动端生成页体验文档。 +- 验证方式:拼图与抓大鹅生成页在手机竖屏下只显示等待与计时双栏,步骤卡按顺序滑入;其它未传入隐藏参数的生成页继续保留原批次模块。 +- 关联文档:`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。 + +## 2026-05-14 移动端输入法弹出时平台画布不压缩 + +- 背景:平台根壳使用 `100dvh` 后,手机浏览器输入法弹出会让可见视口变小,导致创作首页、推荐页等固定游戏式画布被重新压缩。 +- 决策:主站入口统一注册移动端输入法聚焦适配;输入法未打开时记录稳定布局高度,输入法打开期间 `.platform-viewport-shell` 不跟随 `visualViewport.height` 缩小,只通过 `--platform-keyboard-focus-offset` 上移画面聚焦当前输入框,并临时隐藏移动端底部 dock。 +- 影响范围:主站平台壳、移动端创作首页底部输入框、后续所有复用 `.platform-viewport-shell` 的输入表单;业务组件不重复注册键盘适配。 +- 验证方式:手机竖屏点击输入框,画布不压缩,输入框移动到输入法上方;输入法关闭后画布回位,底部 dock 恢复。 +- 关联文档:`docs/technical/【前端体验】移动端输入法不压缩画布聚焦方案-2026-05-14.md`、`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。 + ## 2026-05-14 抓大鹅物品素材批量重新生成复用 item-assets 替换模式 - 背景:抓大鹅结果页 `素材配置 > 物品` 需要在不改变玩法物品映射的前提下,批量重新生成已存在物品的 2D 五视角图片。 @@ -48,6 +80,8 @@ - 验证方式:草稿页作品卡与分类页列表视觉口径保持一致;`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/design/MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md`、`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。 +2026-05-14 补充:草稿页作品卡不再用“草稿 / 已发布”文字标识状态,改为图标化 UI 状态点;作品封面直接铺到卡片右半区并从右向左渐隐;已发布作品右上角常驻分享图标;草稿长按弹出删除面板,已发布长按弹出分享和删除面板。 + ## 2026-05-13 认证运行期同步直接导入正式认证表 - 背景:`auth_store_snapshot` 是 Stage 1 整包快照过渡表,主键固定 `default`,会让所有用户状态集中在一条 `snapshot_json` 中;Stage 2/3 已有 `user_account/auth_identity/refresh_session` 正式认证表,继续刷新 `default` 容易让运行时真相和表拆分目标混在一起。 @@ -346,6 +380,14 @@ - 验证方式:检查移动端底部导航文案和顺序,确认登录态为“推荐/发现/创作/草稿/我的”,未登录态为“推荐/创作/发现”且创作居中;“推荐”无搜索/频道栏直出作品流,“发现”包含搜索/推荐/今日/分类/排行,“创作”只显示新建入口,“草稿”显示作品架,“我的-玩过”可恢复存档。 - 关联文档:`docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md`。 +## 2026-05-14 推荐页卡片主视觉优先于底部作者热区 + +- 背景:移动端推荐页的卡片底部作者与操作区如果过高,会压缩作品运行态可视高度,影响首屏沉浸感。 +- 决策:推荐页卡片底部信息区保持紧凑固定高度,切换手势仍只绑定在该区域;视觉主体高度优先扩展,不再让作者信息区占用过多首屏空间。 +- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx` 的推荐页卡片布局,以及 `src/index.css` 中的推荐页卡片热区样式。 +- 验证方式:移动端推荐页首屏应明显看到更大的作品内容区,底部作者信息区只保留紧凑一条,不再明显挤压运行态。 +- 关联文档:`docs/technical/PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md`。 + ## 2026-05-05 创作 Tab 固定为智能创作首页,草稿 Tab 承接旧作品架 - 背景:创作首页需要变成面向对话式生成的智能创作页,旧模板卡和作品架继续保留但不应再占据创作首屏。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 17303bc9..e4a67fee 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -14,6 +14,22 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 图像输入组件不要把业务状态藏在页面内联实现里 + +- 现象:拼图页把参考图上传、缩略图、主图删除确认和 AI 重绘开关内联实现后,后续想复用到其它创作页时,页面级状态和通用 UI 状态混在一起,容易出现多套上传卡和参考图展示口径。 +- 原因:通用图像输入是受控输入面板,不是只服务单页的临时实现;图片、提示词、参考图数组、重绘开关等业务真相应由外层页面持有,组件最多持有参考图预览、删除确认这类短生命周期 UI 状态。 +- 处理:抽 `CreativeImageInputPanel` 时,保留上传卡、参考图入口、缩略图、预览弹层、删除确认和提交按钮的统一壳,但把主图文件读取、裁剪、历史素材、计费确认和具体提交动作留给外层页面;后续页面接入时只传业务回调和文案。 +- 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。 +- 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。 + +## 汪汪声浪入口不要再回到独立配置阶段 + +- 现象:汪汪声浪入口如果继续切换到独立配置阶段,会和拼图、抓大鹅的创作页内嵌结构不一致,用户会感觉入口跳页。 +- 原因:旧实现把 `bark-battle` 单独挂到 `bark-battle-config` selectionStage,而不是复用创作 Tab 里的模板区。 +- 处理:入口点击只设置 `activeCreationFormType = 'bark-battle'` 并回到创作 Tab;`BarkBattleConfigEditor` 作为内嵌表单使用,默认隐藏返回按钮和页面标题;runtime `onExit` 重新回到创作 Tab 的汪汪声浪模板。 +- 验证:点击汪汪声浪后直接看到创作页内嵌表单,不再出现独立配置页;测试应覆盖内嵌表单与 runtime 返回路径。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。 + ## 抓大鹅批量重新生成物品不要新增 itemId - 现象:结果页批量重新生成物品后,试玩或正式运行态的物品类型和图片对应关系漂移,或者用户输入一个不存在名称后被当作新物品追加。 @@ -22,6 +38,14 @@ - 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx` 覆盖前端提交口径,`cargo test -p api-server match3d_item_asset --manifest-path server-rs\Cargo.toml` 和 `cargo test -p api-server match3d_regenerated_asset --manifest-path server-rs\Cargo.toml` 覆盖后端替换计划与身份保留。 - 关联:`src/components/match3d-result/Match3DResultView.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`packages/shared/src/contracts/match3dWorks.ts`、`server-rs/crates/shared-contracts/src/match3d_works.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 +## 抓大鹅生成封面图不要覆盖物品素材或配置 + +- 现象:结果页生成封面图后,`素材配置 > 物品` 中已有物品素材被清空、回退旧快照,或难度 / 消除次数被改回旧值。 +- 原因:封面生成属于定向图片槽位更新;若后端复用草稿编译写回,可能按 session config 重算作品行。即使后端已修正,前端若直接把封面接口返回的整份 `item` 当成最新 profile,也可能用旧回包里的空 `generatedItemAssets` 覆盖当前页面素材。 +- 处理:`POST /api/creation/match3d/works/{profileId}/cover-image` 只保存 `coverImageSrc` / `coverAssetId` 等封面字段,保留当前 `generated_item_assets_json`、难度、消除次数、题材和描述;前端收到回包后只合并 `coverImageSrc`,继续保留当前可见 `generatedItemAssets`、`clearCount` 和 `difficulty`。 +- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx` 覆盖旧回包不覆盖物品素材和配置;`cargo test -p api-server match3d_cover --manifest-path server-rs\Cargo.toml` 覆盖封面提示词与参考图链路。 +- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/spacetime-module/src/match3d/mod.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 + ## OSS V4 签名时间和 bucket/object_key 兼容 - 现象:OSS V4 私有读签名在部分时间点失败,可能出现 `OSS V4 签名时间格式化失败` 或服务端判定签名格式错误;排查用例中 bucket 为 `xushi-dev`,object_key 为 `generated-square-hole-assets/.../image.png`。 @@ -71,12 +95,20 @@ - 现象:点击生成抓大鹅草稿后,页面只提示“服务暂不可用”,或者本地 `npm run api-server` 看似启动但生成接口不可用。 - 原因:配置缺失类错误通常在后端 `error.details.reason` 中给出具体缺项,前端如果只读 `details.message` 会吞掉原因;本地只配置 `ALIYUN_OSS_BUCKET` / `ALIYUN_OSS_ENDPOINT` 时,旧逻辑还会在启动期构造空 AccessKey 的 OSS 客户端并失败。抓大鹅新链路仍是 2D 生图切割,不需要也不应回退 Rodin/GLB。 -- 处理:前端 API 错误展示读取 `details.message` 后继续读取 `details.reason`;`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_*` 四件套。抓大鹅 `5*5` 素材图提示词还必须要求相邻物体主体至少保留 `1/4` 单格宽度空白间距,避免切割后相邻格内容污染。 - 验证:`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 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}` 排查。 +## 抓大鹅发布按钮要先开发布面板,封面编辑收口到发布面板内 + +- 现象:抓大鹅结果页发布按钮看起来点不了,或者封面编辑仍然分散在作品信息 Tab 里,和拼图发布体验不一致。 +- 原因:发布按钮被 `publishReady` 直接禁用,导致未满足门槛时无法进入发布检查面板;封面编辑仍挂在作品信息 Tab,不能和发布检查一起收口。 +- 处理:发布按钮只受忙碌态控制,点击后始终打开独立发布面板;发布面板内先展示阻断项,再承载封面图上传 / AI 重绘 / 参考图编辑,满足条件后再点击 `发布到广场`。 +- 验证:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`;`npm run typecheck`。 +- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-result/Match3DResultView.test.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 + ## `.hermes` 只放共享内容,不放个人 Hermes 配置 - 现象:团队成员误把个人 Hermes 配置、会话或密钥复制进仓库。 @@ -195,7 +227,7 @@ - 现象:拼图有参考图时返回 `拼图图片生成失败:创建拼图 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 发送。 -- 验证:`curl -i -X POST https://api.vectorengine.ai/v1/images/edits -H "Authorization: Bearer invalid" -F "model=gpt-image-2-all" -F "prompt=test" -F "n=1" -F "size=1024x1024"` 至少应返回 HTTP `401`,说明域名、TLS 和路径可达;执行 `cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`。 +- 验证:`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`。 ## 拼图 UI 背景缺失先区分生成失败和消费链路丢字段 @@ -225,7 +257,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=180000`;若上游在该窗口内未返回,后端退款并返回超时错误。旧前端 action 写请求会对 502/503/504 自动重试一次,导致同一次点击重复触发生图与扣退费。 +- 原因:首图生成走 VectorEngine `gpt-image-2-all`,默认 `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 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`。 @@ -372,6 +404,7 @@ - 本地启动脚本没有让 `.env.local` 覆盖 `.env`,`SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`。 - Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500`,`AuthGate` 降级成 `["password"]`。 - 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002;浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。 + - 生成页 UI 改动看起来“完全没变化”时,也要先确认当前浏览器打开的 Vite 进程正在返回最新源码;例如直接请求 `http://127.0.0.1:3000/src/components/CustomWorldGenerationView.tsx` 检查是否包含本次新增类名或关键字。 - 单独 `npm run dev:web` 启动瞬间另一个临时 API 端口可用,脚本若自动切过去,之后临时 API 停掉也会让 3000 继续代理到空端口。 - 处理:优先用 `npm run api-server`、`npm run dev:rust` 或 `npm run dev` 启动,这些入口应保持 shell 环境变量最高优先级,并允许 `.env.local` 覆盖 `.env`;完整栈启动时还要确保脚本计算出的 `RUST_SERVER_TARGET` 不被 `.env.local` 里的旧值覆盖。排查时先请求 3000 域名下的 `/api/auth/login-options`,再直连 Rust API 目标,并核对 `.env.local` 的 `SMS_AUTH_ENABLED` 与代理端口;若 3001/3002 才返回正确结果,说明当前 3000 是旧前端进程,应清理旧进程后重启。 - 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}` 后,登录弹窗会恢复短信登录页签和“获取验证码”按钮。 @@ -729,11 +762,19 @@ ## 抓大鹅物品切图白边或绿幕残留先查后端透明化 - 现象:抓大鹅生成的物品视角图裁剪后仍带白边,或者整块纯绿色绿幕背景没有被透明化,运行态看到绿色方块。 -- 原因:素材 sheet 可能是“每格内部绿幕、整张图外圈近白底”,内部绿幕不一定连通到 sheet 外边缘;旧 flood fill 只从外边缘找背景会漏掉这种绿幕块。白底抗锯齿如果不纳入抠像和边缘去污染,也会随裁剪输出成一圈白边。 -- 处理:`api-server` 的 `slice_match3d_material_sheet` 必须先在整张 sheet 上做透明背景后处理:外边缘连通绿幕/近白底清 alpha,非连通但高置信纯绿块也清 alpha,边缘近白和绿幕抗锯齿做透明或去污染;同时保护不够纯的绿色主体像素。 +- 原因:素材 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 切图。 - 关联:`server-rs/crates/api-server/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 +## 抓大鹅物品详情大方格只做单张大图查看 + +- 现象:结果页 `素材配置 > 物品` 打开详情后,上方大方格仍显示横向五图带、焦点内框或小缩略图边框,物品本体看起来偏小且像带着素材自带边框。 +- 原因:旧预览把上方区域当作横向视角带,当前焦点只是带内缩略图的一张,视觉上不是“详细查看物品形象”的大图。 +- 处理:上方方格只渲染当前选中的单张大图,使用 `object-contain` 和少量内边距放大查看;底部缩略图栏负责切换视角,缩略图可以保留选中态边框,但上方大图不渲染焦点内框或缩略图容器边框。 +- 验证:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx` 覆盖上方大图、底部缩略图和视角切换。 +- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 + ## 草稿页卡片有真实素材但仍显示黑卡先查摘要字段 - 现象:草稿页拼图卡片没有关卡图背景,抓大鹅卡片没有背景图或物品图背景,甚至兜底视觉也退回黑色面板。 @@ -766,12 +807,12 @@ - 验证:结果页 UI Tab、`startLocalPuzzleRun` 和 `PuzzleRuntimeShell` 都应在仅有 `objectKey` 时显示生成背景,不再回落默认 UI。 - 关联:`src/services/puzzle-runtime/puzzleUiBackgroundSource.ts`、`src/components/puzzle-result/PuzzleResultView.tsx`、`src/services/puzzle-runtime/puzzleLocalRuntime.ts`、`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`server-rs/crates/module-puzzle/src/application.rs`。 -## 拼图 UI 背景提示词不像 AI 生成先查首关命名契约 +## 拼图 UI 背景提示词或作品元信息异常先查首关命名契约 -- 现象:拼图草稿生成完成后,`素材配置 > UI` 里显示的 `UI背景提示词` 像前端或后端模板拼接,而不是 AI 生成的视觉提示词。 -- 原因:首关命名 LLM 旧契约只返回 `levelName`,自动 UI 背景阶段只能用作品名、作品描述、关卡描述和标签拼接确定性兜底提示词;前端旧实现又会在 `uiBackgroundPrompt` 为空时把本地默认模板直接填进文本框,造成“看起来已有 AI 提示词”的假象。 -- 处理:首关命名 LLM 契约必须同时返回 `{"levelName":"...","uiBackgroundPrompt":"..."}`;草稿自动 UI 背景生成优先使用该 AI 提示词,视觉精修请求若返回新提示词则覆盖文本请求提示词,否则保留文本请求提示词。前端文本框只展示已保存的 `uiBackgroundPrompt` 或用户编辑值,字段为空时不展示本地兜底模板。 -- 验证:执行 `cargo test -p api-server puzzle_level_naming --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server puzzle_initial_ui_background_prompt --manifest-path server-rs\Cargo.toml`、`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`。 +- 现象:拼图草稿生成完成后,第一关名称或作品名称变成 `levelNam` / `levelName` 这类字段名片段,或 `素材配置 > UI` 里显示的 `UI背景提示词` 像前端或后端模板拼接,而不是 AI 生成的视觉提示词。 +- 原因:首关命名 LLM 旧契约只返回 `levelName`,自动 UI 背景阶段只能用作品名、作品描述、关卡描述和标签拼接确定性兜底提示词;如果模型返回截断 JSON,解析层还可能把 `levelNam` 这类字段名片段当作普通英文关卡名归一化通过。 +- 处理:首关命名 LLM 契约必须同时返回 `{"levelName":"...","workDescription":"...","workTags":["..."],"uiBackgroundPrompt":"..."}`;解析层必须拒绝 `levelNam`、`levelName`、`workDescription`、`workTags`、`uiBackgroundPrompt` 等字段名片段作为关卡名。草稿自动 UI 背景生成优先使用该 AI 提示词,作品描述和 6 个作品标签默认填入草稿;视觉精修请求若返回新提示词或作品元信息则覆盖文本请求结果,否则保留文本请求结果。前端文本框只展示已保存的 `uiBackgroundPrompt` 或用户编辑值,字段为空时不展示本地兜底模板。 +- 验证:执行 `cargo test -p api-server puzzle_level_naming_parser --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server puzzle_first_level_name --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server puzzle_initial --manifest-path server-rs\Cargo.toml`、`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`。 - 关联:`server-rs/crates/api-server/src/prompt/puzzle/level_name.rs`、`server-rs/crates/api-server/src/puzzle.rs`、`src/components/puzzle-result/PuzzleResultView.tsx`、`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。 ## 拼图 / 抓大鹅 UI 背景重生成报 No such procedure 先查 SpacetimeDB 版本漂移 @@ -781,3 +822,19 @@ - 处理:临时容错是把这类 `No such procedure` 当作后端版本漂移:泥点预扣阶段跳过扣费,图片已经生成但保存失败时返回本次内存快照 / 内存 profile,避免草稿页直接报错。长期修复仍是发布最新 `spacetime-module`、重新生成 bindings,并用 `spacetime describe` 或定向 smoke 确认 procedure 已导出。 - 验证:`cargo test -p api-server asset_operation_billing_skips_spacetime_connectivity_errors --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server match3d_fallback_work_profile_keeps_generated_background_asset --manifest-path server-rs\Cargo.toml`、`npm run api-server` 后检查 `/healthz`。 - 关联:`server-rs/crates/api-server/src/asset_billing.rs`、`server-rs/crates/api-server/src/match3d.rs`、`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 + +## 拼图合并块拖起后原位置出现红色块先查选中态泄漏 + +- 现象:拼图运行态中,多个拼图片合并后拖起整体块,原位置会露出一块粉红 / 红色底色。 +- 原因:合并块拖拽的可见层来自 `mergedGroups` 绝对定位整体层,但 `pointerdown` 会同步写入 `selectedPieceId`;若棋盘格里的底层单块 DOM 先匹配选中态,再匹配合并态,整体层移开后就会露出单块选中填充色。 +- 处理:合并格底层 DOM 只作为透明定位占位,`isSelected` 必须排除 `isMerged`;合并格样式优先级高于单块选中态。 +- 验证:运行 `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`。 + +## 拼图历史图片列表不要把账号归属当图片名 + +- 现象:拼图创作页或结果页打开“选择历史图片”后,历史列表显示 `账号 user-1` 之类归属文案而不是图片名;`1713686400.000000Z` 这类时间显示为未知;选中后预览或生成参考图可能被怀疑不可用。 +- 原因:`/api/assets/history?kind=puzzle_cover_image` 返回的 `ownerLabel` 是资产归属账号,不是图片标题;`createdAt` 可能是 SpacetimeDB / shared-kernel 秒级时间字符串,不能只用浏览器 `new Date(value)` 解析。历史图的 `imageSrc` 是 `/generated-*` 私有兼容路径,浏览器预览必须换签。 +- 处理:前端标题和选中标签从 `imageSrc` 路径末尾推导,例如 `image.png`;时间解析兼容 ISO 与 `1713686400.000000Z`;创作页主图、历史列表图和结果页参考图继续用 `ResolvedAssetImage`,提交给后端时仍保留原始 `imageSrc`。 +- 验证:`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`,并执行 `npm run check:encoding`。 +- 关联:`src/services/puzzle-works/puzzleHistoryAsset.ts`、`src/components/puzzle-agent/PuzzleHistoryAssetPickerDialog.tsx`、`docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md`。 diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 4637302f..7420d6c9 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -41,7 +41,7 @@ APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn VECTOR_ENGINE_API_KEY= -VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000 +VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000 VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS=180000 HYPER3D_BASE_URL=https://api.hyper3d.com/api/v2 diff --git a/docs/design/MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md b/docs/design/MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md index 4c6f33fd..0b79c134 100644 --- a/docs/design/MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md +++ b/docs/design/MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md @@ -4,24 +4,24 @@ 草稿页的作品模块需要同时承载 RPG、拼图和大鱼吃小鱼等玩法。不同玩法卡片不能各自展示阶段、素材、主题等细节标签,否则作品列表会在移动端显得拥挤,并且草稿作品会暴露过多编辑态信息。 -本次将作品列表卡片收口成统一信息结构:草稿只用于快速识别和继续创作,已发布作品才展示公开数据;删除与分享等低频操作收进左滑操作层,避免列表常态被按钮挤占。 +本次将作品列表卡片收口成统一信息结构:草稿只用于快速识别和继续创作,已发布作品才展示公开数据;删除与分享等低频操作收进长按动作面板,避免列表常态被按钮挤占。 ## 落地范围 - 列表容器:`src/components/custom-world-home/CustomWorldCreationHub.tsx` - 作品卡片:`src/components/custom-world-home/CustomWorldWorkCard.tsx` - 不改动作品数据聚合、筛选、打开和体验逻辑。 -- 已发布作品保留分享能力;可删除作品保留删除能力,但常态不显示为右侧按钮。 +- 已发布作品保留分享能力,卡片右上角常驻分享图标;可删除作品保留删除能力,但常态不显示删除按钮。 ## 卡片结构规则 -1. 卡片整体对齐发现 / 分类页的横向作品列表结构:左侧为标题、状态、类型、摘要与必要数据,右侧为带透明度的封面图。 +1. 卡片整体对齐发现 / 分类页的横向作品列表结构:内容层承载标题、状态、类型、摘要与必要数据,封面作为不占内容布局的右半区透明背景层。 2. 不再显示阶段、主题、素材完成度、作者、作品号等额外标签。 -3. 标题区域保留作品状态与游戏类型;生成中的草稿状态显示为“生成中”。 +3. 标题区域保留作品状态与游戏类型;草稿和已发布状态只用图标化 UI 标识,不再在卡片上显示“草稿 / 已发布”文字。 4. 草稿卡片到作品描述为止,不显示右侧“继续创作”等固定动作、统计、作品号或公开指标。 5. 已发布卡片在描述下方显示三项公开指标:游玩数、改造数、点赞数。 -6. 已发布卡片的分享入口收进左滑操作层,点击后复制作品分享文案,不触发卡片打开。 -7. 可删除卡片的删除入口收进左滑操作层,常态不显示删除按钮;左滑露出后点击删除不触发卡片打开。 +6. 已发布卡片右上角显示分享图标,点击后复制作品分享文案,不触发卡片打开。 +7. 长按草稿作品弹出独立动作面板,只展示删除作品;长按已发布作品弹出独立动作面板,展示分享和删除。动作按钮点击后不得触发卡片打开。 8. 卡片不显示最后修改时间;`updatedAt` 只用于作品列表排序。 9. 生成中的卡片在整卡上叠加半透明蒙版、旋转等待符号和“生成中...”标识;蒙版不能移除标题、状态、类型、摘要、右侧封面等原有信息。 @@ -32,7 +32,7 @@ 3. 用户每次进入创作页时,前端读取上一次进入该页面缓存的公开指标快照;当已发布作品卡片滑动进入视口后,数字从缓存值增长到本次接口返回的最新值。 4. 若最新值高于缓存值,动画完成后在对应指标右下角展示红色向上箭头和本次上涨的具体数值,字号低于主数字,避免抢占主信息层级。 5. 若没有缓存值、缓存值不低于最新值或作品仍是草稿,则直接显示最新值,不展示上涨标记。 -6. 每张作品卡片继续使用作品封面作为右侧方形半透明主视觉;封面不能因为列表收缩被拉伸变形。 +6. 每张作品卡片继续使用作品封面作为右侧半区透明背景主视觉;封面从右向左渐隐,不能出现独立方形边界,也不能因为列表收缩挤占正文布局或被拉伸变形。 7. 作品列表按 `updatedAt` 倒序排列;前端排序需要兼容 ISO 时间和 Rust 后端常用的 `seconds.microsZ` 时间文本。 8. 若玩法摘要缺少 `coverImageSrc`,允许从同一作品的正式关卡图、背景图或素材图里取第一张可用图片作为卡片背景兜底。 9. 若作品真实图片为空、私有资源换签失败或浏览器图片加载失败,卡片必须切到对应玩法的 `public/creation-type-references/` 参考图;最终兜底底色使用平台浅粉暖白色系,不允许退回黑色普通面板。 @@ -40,16 +40,16 @@ ## 移动端布局规则 1. 作品列表默认使用单列纵向列表,视觉上与发现页分类列表保持一致。 -2. 移动端每张卡片使用固定右列方形半透明封面,右列建议 `5.1rem` 左右;草稿卡即使复用分类页基础类名,也必须用自身选择器覆盖分类页的 `4.3rem + 正文 + action` 三列规则,避免正文被压进窄列。 -3. 已发布作品的公开指标在卡片正文内保留,但需要压缩字号和间距,不能让右侧封面列错位。 +2. 移动端每张卡片使用绝对定位右半区封面背景层,封面在右边缘最清晰、向左渐隐;草稿卡即使复用分类页基础类名,也必须用自身选择器覆盖分类页的 `4.3rem + 正文 + action` 三列规则,避免正文被压进窄列。 +3. 已发布作品的公开指标在卡片正文内保留,但需要压缩字号和间距,不能依赖右侧封面列参与排版。 4. 小屏卡片降低高度、内边距、标题字号和徽标尺寸,避免长标题或中文描述撑破容器。 -5. 右侧封面容器本身必须带玩法参考图 CSS 背景兜底;`img` 的真实封面或 `ResolvedAssetImage` fallback 加载失败时,也不能出现空白或黑卡。 +5. 右侧封面层本身必须带玩法参考图 CSS 背景兜底;`img` 的真实封面或 `ResolvedAssetImage` fallback 加载失败时,也不能出现空白或黑卡。 ## 网页端布局规则 1. 网页端作品架不能继续拉成整行超宽列表;从 `768px` 起使用多列卡片式网格,默认两列,宽屏提升到三列。 2. 网页端卡片保留移动端同一信息结构,但卡片高度增加,正文区可显示更多摘要与公开指标,右侧封面改为更高的半透明视觉区。 -3. 删除与分享仍然只在左滑或键盘揭示态显示;默认态不得透出红色删除底层或分享底层。 +3. 删除与分享仍然只在长按动作面板或键盘揭示态显示;默认态不得透出红色删除底层。 ## 文案约束 diff --git a/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md b/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md index 94007d11..e6b59adc 100644 --- a/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md +++ b/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md @@ -60,3 +60,5 @@ 3. 风格卡标签使用浅底胶囊,保证图片仍是主体。 4. 难度等分段选项可以使用主品牌色,但选中态需要降低阴影和饱和度。 5. UI 中不补充玩法规则说明文案,保持创作入口清爽。 +6. 拼图创作表单在未上传主图时,画面描述输入框右下角保留一个参考图上传入口;支持多选,最多 5 张,上传后以下方小缩略图展示,点击缩略图可放大预览。 +7. 当前拼图后端只消费第一张有效参考图做生成,前端仍需保留数组输入,方便后续扩展多参考图能力。 diff --git a/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md b/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md index 96cf7523..58215816 100644 --- a/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md +++ b/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md @@ -126,6 +126,7 @@ - 用户再进入草稿 Tab 并点击同一草稿时,若生成仍未完成,进入对应生成过程页查看最新进度;若已完成,直接进入对应结果页。 - 草稿作品卡在生成中展示“生成中”状态标记;新生成完成且用户尚未查看的草稿在卡片右上角展示红点。 - 底部一级“草稿”Tab 在存在未查看新完成草稿时展示红点;用户点击查看带红点的作品后,该作品红点消失。若草稿页已无任何带红点作品,底部“草稿”Tab 红点同步消失。 +- 红点通知链路覆盖所有进入草稿作品架的生成型玩法;宝贝识物等仅有 `profileId` 和 `draftId` 的轻量草稿,也必须把两个 ID 都纳入同一组通知 key,避免卡片红点和底部草稿 Tab 红点漂移。 - 生成完成时如果用户仍停留在对应生成过程页,可自动进入结果页;如果用户已经回到创作中心或其它功能页,不打断当前操作。 - 创作 Tab 的模板入口只允许被模板自身的开放状态禁用;某个草稿后台生成中时,不得用该玩法的 busy 状态禁用其它模板入口、同模板再次创建入口或阻止用户继续创建新作品。 - 同模板再次点击生成时必须创建新的草稿生成任务,不得因为当前玩法已有后台生成 session 就跳回上一条草稿的生成过程页;查看上一条生成进度只能从草稿 Tab 的对应作品卡进入。 diff --git a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md index 870941e4..691c2e47 100644 --- a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md +++ b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md @@ -143,13 +143,13 @@ 5. 标签 ## 6.3 草稿页作品卡对齐分类列表 -- 草稿 Tab 的作品架要优先对齐发现页分类列表的横向卡片:左侧承载标题/状态/类型/摘要,右侧显示带透明度的方形封面图。 -- 草稿卡不能为了视觉对齐丢掉原有信息:删除、分享、已发布统计、拼图积分激励、未读红点都要保留;其中删除和分享属于低频动作,常态不显示按钮,向左划动列表项后露出操作。 +- 草稿 Tab 的作品架要优先对齐发现页分类列表的横向卡片:内容层承载标题/状态/类型/摘要,封面作为右半区半透明背景层。 +- 草稿卡不能为了视觉对齐丢掉原有信息:删除、分享、已发布统计、拼图积分激励、未读红点都要保留;其中删除属于低频动作,常态不显示按钮,长按列表项后进入独立动作面板;已发布作品右上角可以常驻分享图标。 - 卡片右侧不再常驻“继续创作”“查看详情”“查看进度”等动作按钮,打开作品由整张卡片承担。 - 移动端保持单列列表;网页端使用多列卡片式网格,避免在宽屏上把作品卡拉成一整行长条。 - 生成中的状态使用整卡蒙版、旋转等待符号和“生成中...”标识;蒙版只能作为状态层,不能替换或移除卡片本身的信息。 - 草稿卡复用分类页基础类名时,要用 `.creation-work-card.platform-category-game-item` 覆盖分类页移动端三列规则;否则正文会被当作封面列压缩,中文标题会断成一两个字一行。 -- 右侧封面不要只依赖 `img` fallback,容器层也要有玩法参考图 CSS 背景兜底,私有资源换签失败或图片 onerror 时仍能看到封面视觉。 +- 右侧封面不要只依赖 `img` fallback,容器层也要有玩法参考图 CSS 背景兜底,私有资源换签失败或图片 onerror 时仍能看到封面视觉;封面层适合绝对定位铺到卡片右半区,作为半透明背景从右到左渐隐,不应出现独立方形边界或参与内容排版。 ## 7. 样式与动画经验 @@ -235,3 +235,12 @@ - 主站移动端以固定游戏画布体验为准,入口 `viewport` 需要锁定 `minimum-scale=1.0`、`maximum-scale=1.0` 和 `user-scalable=no`,同时保留 `viewport-fit=cover` 适配安全区。 - 浏览器仍可能通过 iOS `gesture*` 或多指 `touchmove` 触发整页缩放,因此主站启动入口应统一调用 `lockMobileViewportZoom()` 拦截页面级捏合与快速双击缩放。 - 不要在每个画布组件里重复注册缩放拦截;单指滚动、点击、拖拽应继续留给具体页面和玩法处理。 + +### 10.4 移动端输入法不要压缩画布 +- 平台主站点击输入框弹出输入法时,不能让 `100dvh` 跟随键盘缩小后重新压缩整页布局。 +- 正确做法是在输入法未打开时记录稳定布局高度,输入法打开期间保持画布高度不变,只根据当前输入框位置计算画面上移量。 +- 该行为应在主站入口统一注册,业务组件只保留普通 `input` / `textarea`,不要在每个输入框里重复写键盘适配逻辑。 + +### 10.5 移动端创作生成页不要暴露批次视角 +- 拼图、抓大鹅这类轻量玩法的草稿生成页只保留“预计等待”和“计时”两个用户关心的状态,移动端放在同一行;不要默认展示“当前批次”这类模型执行视角。 +- 生成步骤在移动端进入页面时按顺序从左侧滑入,强化“正在推进”的节奏;动画只绑定步骤卡,不影响桌面端密集布局和其它信息卡。 diff --git a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md index 25ce63db..bdeb828d 100644 --- a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md @@ -348,6 +348,7 @@ interface PuzzleAnchorPack { 7. 历史拼图素材库读取 `asset_kind = puzzle_cover_image` 的资产记录,只用于选择参考图,不直接替换正式图。 8. 从历史素材库选择素材后,前端把该素材的 `imageSrc` 作为 `referenceImageSrc` 传入下一次生成请求。 9. 本地上传参考图与历史素材参考图互斥;后选择者覆盖先选择者。 +10. 历史素材列表的图片名称必须从 `imageSrc` 的路径末尾推导,不能把 `ownerLabel` 账号归属文案当成图片名称;生成时间必须兼容 SpacetimeDB 秒级时间字符串。 前端 UI 规则: diff --git a/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md b/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md index a2420fe8..1cbc5e03 100644 --- a/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md +++ b/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md @@ -80,6 +80,7 @@ - `PlatformHomeView` 继续作为“我的”Tab 首屏承载层 - 优先采用现有面板、抽屉、弹窗,不新建独立大系统 - 页面只展示后端返回的状态,不自行计算结论型业务状态 +- 会员中心与充值入口只保留在顶部身份卡右侧按钮,不在“常用功能”区重复展示 - 每日任务入口放在“常用功能”,点击后弹出独立任务面板 ### 4.2 后端边界 diff --git a/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md b/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md index f72f5706..dffed120 100644 --- a/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md +++ b/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md @@ -75,6 +75,8 @@ 3. 中部显示权益卡片 4. 底部显示套餐与购买按钮 +2026-05-14 补充:充值入口只保留在“我的”页顶部身份卡右侧按钮,常用功能区不再重复展示充值卡片,避免同屏出现两个相同业务入口。 + ## 4.2 页面内容 页面展示模块: diff --git a/docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md b/docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md index 87b2b47a..793aa4ed 100644 --- a/docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md +++ b/docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md @@ -42,7 +42,7 @@ APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 # VectorEngine / Gemini 原生图片 / GPT-image-2 / Suno / Vidu 生成网关 VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn VECTOR_ENGINE_API_KEY= -VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000 +VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000 VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS=180000 # Hyper3D Rodin Gen-2 3D 模型生成 @@ -102,14 +102,14 @@ HYPER3D_MODEL_REQUEST_TIMEOUT_MS / RODIN_MODEL_REQUEST_TIMEOUT_MS 3. 文本 LLM provider 为 `ark` 且未配置 `GENARRATIVE_LLM_BASE_URL` 时,仍回退到 Ark 公开基础 URL。 4. 角色视频 provider 复用 Ark 且未配置 `ARK_CHARACTER_VIDEO_BASE_URL` 时,仍回退到 Ark 公开基础 URL。 5. 具体模型名缺失时不在配置层伪造默认模型,调用到对应能力时由下游配置校验返回缺配置错误。 -6. VectorEngine 图片与音频生成只读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`,其中 GPT-image-2 与抓大鹅 Gemini 素材 sheet 图片生成额外读取 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;不复用 `APIMART_*`、`GENARRATIVE_LLM_*` 或前端变量。拼图 Agent 的生成 action 不做前端自动重试,避免一次点击在上游超时后重复触发外部生图与钱包扣退费;若 VectorEngine 请求达到该超时窗口,api-server 返回 `504 Gateway Timeout`,`error.details.provider` 为 `vector-engine`,并保留具体超时 message。 +6. VectorEngine 图片与音频生成只读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`,其中 GPT-image-2 与抓大鹅 Gemini 素材 sheet 图片生成额外读取 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;不复用 `APIMART_*`、`GENARRATIVE_LLM_*` 或前端变量。图片请求默认超时窗口为 `1000000ms`,且 api-server 会把旧环境中较小的 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 提升到该下限,避免 500 秒级生图被提前截断。拼图 Agent 的生成 action 不做前端自动重试,避免一次点击在上游超时后重复触发外部生图与钱包扣退费;若 VectorEngine 请求达到该超时窗口,api-server 返回 `504 Gateway Timeout`,`error.details.provider` 为 `vector-engine`,并保留具体超时 message。 7. 火山引擎语音能力由 `platform-speech` 收口协议帧与上游鉴权,`api-server` 只暴露平台鉴权后的代理路由,不向前端返回任何密钥字段。 8. Hyper3D Rodin Gen-2 使用公开默认 `https://api.hyper3d.com/api/v2`,API Key 只读取 `HYPER3D_API_KEY` / `RODIN_API_KEY`,不复用文本 LLM、图片或音频网关密钥。 9. APIMart 当前只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态理解链路;抓大鹅物品素材 sheet、GPT-image-2 图片生成和音频生成都不得读取 APIMart 配置。 10. 本地 `npm run api-server`、`npm run dev:rust`、`npm run dev` 与 `npm run dev:web` 的环境文件优先级固定为非空外层 shell 变量最高,其后 `.env`、`.env.local`、`.env.secrets.local` 逐层覆盖;真实密钥建议放在 `.env.secrets.local`,防止 `.env` 中的空示例值覆盖私密配置。外层 shell 变量如果是空字符串或全空白,不再遮蔽本地 env 文件中的真实值。 11. OSS 客户端只在 `ALIYUN_OSS_BUCKET`、`ALIYUN_OSS_ENDPOINT`、`ALIYUN_OSS_ACCESS_KEY_ID`、`ALIYUN_OSS_ACCESS_KEY_SECRET` 四项齐全时初始化。四项全部缺失表示未启用 OSS;部分缺失时 `api-server` 记录 warning 并继续启动,具体上传、换签或读取 generated 私有资产的接口返回 `OSS 未完成环境变量配置`,并在 `error.details.missingEnv` 中列出缺失变量。 12. 抓大鹅 2D 草稿素材生成需要同时具备 VectorEngine 与 OSS 配置:VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent` 负责生成 5x5 物品素材 sheet;封面和 `9:16` 背景图走 VectorEngine `/v1/images/generations` 的 `gpt-image-2-all` JSON 链路;`1:1` 容器 UI 图走 VectorEngine `/v1/images/edits` multipart 链路,并把 `public/match3d-background-references/pot-fused-reference.png` 作为 `image` part 上传,不能再用 generations `image` 数组弱参考。OSS 负责保存切割后的五视角图片及其它生成图。缺少 VectorEngine 或 OSS 时应通过 `error.details.reason` 向前端暴露具体缺项,不能只显示泛化“服务暂不可用”。素材图、封面图和背景图生成在调用外部生图前必须先预检 OSS,避免已消耗外部生图后才发现无法落库。 -13. 拼图有参考图且开启 AI 重绘时使用 VectorEngine `POST /v1/images/edits` multipart 接口。若返回 `error sending request for url`,代表后端未收到 HTTP 响应;响应 `details` 会带 `reason`、`source`、`connect`、`body`、`timeout` 和 `endpoint`,排查时优先检查服务器网络、DNS、防火墙、代理和参考图大小。拼图图片客户端强制 HTTP/1.1,以降低上游 multipart HTTP/2 连接中断风险。 +13. 拼图有参考图且开启 AI 重绘时使用 VectorEngine `POST /v1/images/edits` multipart 接口。若返回 `error sending request for url`,代表后端未收到 HTTP 响应;响应 `details` 会带 `reason`、`source`、`connect`、`body`、`timeout` 和 `endpoint`,前端展示优先使用 `details.reason`,排查时优先检查服务器网络、DNS、防火墙、代理和参考图大小。拼图图片客户端强制 HTTP/1.1,以降低上游 multipart HTTP/2 连接中断风险。 14. 本地排查 `OSS 未完成环境变量配置` 时必须核对键名是否精确为 `ALIYUN_OSS_ACCESS_KEY_SECRET`。常见误写是把 `OSS` 的首字母 `O` 写成数字 `0`,例如 `ALIYUN_0SS_ACCESS_KEY_SECRET`;该键不会被 `api-server` 读取。 ## 本地配置检查 diff --git a/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md b/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md index dd5b0236..75d990fb 100644 --- a/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md +++ b/docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md @@ -45,6 +45,8 @@ 所有前端可见且会消耗泥点的按钮,点击后必须先弹出独立确认面板,面板标题使用 `确认消耗泥点`,正文只展示本次消耗数量,例如 `消耗 2 泥点`。用户点击 `确定` 后才允许调用后端扣费动作;点击 `取消` 或关闭面板不得触发接口。 +2026-05-14 补充:创作页入口点击确认后,前端必须先刷新 `/api/profile/dashboard` 钱包余额;余额大于等于本次消耗时才允许创建玩法 session / 草稿,余额不足时停留在创作页并展示不足提示。该预检不替代后端扣费原子校验,只用于避免余额明显不足时先生成半成品草稿。 + 2026-05-14 当前已覆盖的草稿页入口包括: - 拼图入口 `AI重绘=true` 的 `生成拼图游戏草稿`:`2` 泥点;`AI重绘=false` 直接使用上传图,不显示泥点确认。 diff --git a/docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md b/docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md index abe8fd50..6c288a46 100644 --- a/docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md +++ b/docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md @@ -13,6 +13,9 @@ 1. `server-rs/crates/api-server/src/assets.rs` 中的历史素材类型白名单统一收口为单一常量源。 2. HTTP 层错误文案与实际支持列表由同一函数生成,避免后续再出现“校验改了但提示文案还是旧口径”的漂移。 3. 增加 `puzzle_cover_image` 的回归测试,确保拼图封面素材不会再次被历史接口遗漏。 +4. `ownerLabel` 只表示资产归属账号,不是历史图片标题;前端历史素材卡片标题必须从 `imageSrc` 的路径末尾推导,例如 `/generated-puzzle-assets/history/image.png` 展示为 `image.png`。 +5. `createdAt / updatedAt` 可能来自 SpacetimeDB / shared-kernel 的秒级字符串,例如 `1713686400.000000Z`,前端不得只用 `new Date(value)` 解析后把它显示成未知时间。 +6. 历史素材选中后仍把 `imageSrc` 作为 `referenceImageSrc` 传给生成链路;创作页和关卡详情页的预览必须通过 `ResolvedAssetImage` 换签展示,不直接请求裸 `/generated-*` 路径。 ## 后续约束 @@ -21,3 +24,7 @@ - `spacetime-module` 的历史素材白名单 - 对应前端调用常量与测试 2. 如果运行态仍返回旧白名单错误,优先检查本地 `api-server.exe` 是否已按最新源码重新编译并重启,而不是先回退前端类型参数。 +3. 历史素材列表的 UI 回归测试应覆盖: + - 卡片标题不使用 `账号 user-1` 这类归属文案。 + - `1713686400.000000Z` 能显示为可读生成时间。 + - 选中素材后工作台 / 关卡详情展示 `历史素材 · image.png`,并继续提交原始 `imageSrc`。 diff --git a/docs/technical/AUTH_NEW_ACCOUNT_INVITE_MODAL_2026-05-01.md b/docs/technical/AUTH_NEW_ACCOUNT_INVITE_MODAL_2026-05-01.md index cd6e06f5..5544a246 100644 --- a/docs/technical/AUTH_NEW_ACCOUNT_INVITE_MODAL_2026-05-01.md +++ b/docs/technical/AUTH_NEW_ACCOUNT_INVITE_MODAL_2026-05-01.md @@ -53,11 +53,18 @@ POST /api/profile/referrals/redeem-code 若用户登录的是已有账号,则不会弹出新账号邀请码面板。 -## 5. 完成定义 +## 5. 新账号泥点初始化 + +当短信登录、开发密码入口或微信激活流程创建新账号时,后端注册链路必须调用 `grant_new_user_registration_wallet_reward`,为该用户写入 `10` 个初始泥点。 + +该赠送落在 `profile_dashboard_state.wallet_balance` 与 `profile_wallet_ledger` 中,流水来源为 `new_user_registration_reward`,流水 ID 固定为 `new-user-registration:{user_id}`,用于保证重复调用不重复发放。已有账号登录不得再次发放。 + +## 6. 完成定义 1. 登录弹窗内不可见注册入口。 2. 短信登录创建新账号后弹出邀请码面板。 3. 邀请码为空时按钮为 `跳过`,非空时按钮为 `提交`。 4. 取消按钮可关闭面板。 5. 已登录邀请码接口允许提交,并继续由 SpacetimeDB procedure 兜底业务校验。 -6. 前端测试覆盖注册入口删除、新账号弹窗、URL 邀请码预填与提交。 +6. 新账号创建成功后默认获得 `10` 个泥点,且重复登录或重试不得重复发放。 +7. 前端测试覆盖注册入口删除、新账号弹窗、URL 邀请码预填与提交。 diff --git a/docs/technical/BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md b/docs/technical/BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md index 77202297..25fb8340 100644 --- a/docs/technical/BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md +++ b/docs/technical/BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md @@ -52,7 +52,7 @@ Phase 2 的作品配置边界是“轻创作配置作品”:创作者可以配 建议先支持“本地 runtime + 可发布配置化作品 + 单局结果记录 + 个人历史成绩 / 作品统计 / 最小排行榜”的闭环: -1. 创作者从玩法选择进入 bark-battle 后创建草稿,通过单页轻配置表单 + 预览卡片配置标题、描述、主题/背景预设、狗狗皮肤预设、难度预设和排行榜开关。 +1. 创作者从玩法选择进入 bark-battle 后创建草稿,通过创作 Tab 内嵌轻配置表单 + 预览卡片配置标题、描述、主题/背景预设、狗狗皮肤预设、难度预设和排行榜开关。 2. 发布为稳定作品 ID,`playTypeId = "bark-battle"`。 3. 玩家可从作品详情页 CTA、广场/作品卡片、我的作品/个人作品架进入正式 runtime,前端使用稳定作品 ID 获取发布态 runtime config。 4. 玩家授权麦克风后在本地完成 30 秒声控对战。 @@ -91,15 +91,15 @@ Phase 2 的个人历史成绩由“最近记录列表 + 个人最佳摘要”组 ### 2.2.6 正式作品入口闭环 -Phase 2 必须接入 Bark Battle 正式作品入口闭环,但不新增独立专区、活动页、挑战分享页、好友邀请或多人房间入口。入口范围包括:创作入口/玩法选择中出现 `bark-battle`,进入单页轻配置表单 + 预览卡片;作品详情页 CTA 点击“开始游玩”进入正式 runtime;广场/作品卡片可以展示、打开详情并开始游玩;我的作品/个人作品架能看到作者发布的 Bark Battle 作品;runtime 路由使用稳定作品 ID 并从后端发布态 config 拉取配置。 +Phase 2 必须接入 Bark Battle 正式作品入口闭环,但不新增独立专区、活动页、挑战分享页、好友邀请或多人房间入口。入口范围包括:创作入口/玩法选择中出现 `bark-battle`,在创作 Tab 模板条下方直接内嵌轻配置表单 + 预览卡片;作品详情页 CTA 点击“开始游玩”进入正式 runtime;广场/作品卡片可以展示、打开详情并开始游玩;我的作品/个人作品架能看到作者发布的 Bark Battle 作品;runtime 路由使用稳定作品 ID 并从后端发布态 config 拉取配置。 正式 run start 成功后必须写 `work_play_start`,其中 `scope_kind=work`、`scope_id=稳定作品 ID`,metadata 至少包含 `playType=bark-battle`、`workId`、`sourceRoute`、`userId`。内部试玩入口可以作为开发调试保留,但不得作为 Phase 2 正式入口。 ### 2.2.7 轻配置编辑流程 -Phase 2 的创作编辑形态是“单页轻配置表单 + 预览卡片”,不是多步骤向导、拖拽编辑器或完整规则编辑器。表单字段包含:标题(必填)、简介(选填)、主题/背景预设(必填枚举)、狗狗皮肤预设(必填枚举)、难度预设(必填,默认 `normal`)、排行榜开关(默认开启)。 +Phase 2 的创作编辑形态是“创作 Tab 内嵌轻配置表单 + 预览卡片”,不是独立配置页面、多步骤向导、拖拽编辑器或完整规则编辑器。表单字段包含:标题(必填)、简介(选填)、主题/背景预设(必填枚举)、狗狗皮肤预设(必填枚举)、难度预设(必填,默认 `normal`)、排行榜开关(默认开启)。 -交互流程:创作者从玩法选择进入后生成草稿;在同一页编辑轻配置并查看预览卡片;支持保存草稿和发布;发布成功后跳转作品详情;可从我的作品再次编辑草稿或基于已发布作品创建新版本。Phase 2 不做 AI 生成配置、多步骤 wizard、规则参数编辑、复杂封面编辑、runtime 内嵌预览或大段玩法说明文案。 +交互流程:创作者从玩法选择进入后保持在创作 Tab;在模板条下方编辑轻配置并查看预览卡片;支持保存草稿和发布;发布成功后跳转作品详情或进入试玩 runtime,runtime 退出时回到创作 Tab 的汪汪声浪模板。可从我的作品再次编辑草稿或基于已发布作品创建新版本。Phase 2 不做 AI 生成配置、多步骤 wizard、规则参数编辑、复杂封面编辑、runtime 内嵌预览或大段玩法说明文案。 ### 2.2 后续增强路径 @@ -115,7 +115,7 @@ Phase 2 按“契约和领域规则先行,然后最小纵切,再扩展投影 1. 契约与领域规则:补 `shared-contracts` DTO、`module-bark-battle` 纯领域规则、`rulesetVersion` / `difficultyPreset` / score adjudication,并先写单测。 2. SpacetimeDB 表与 reducer + api-server BFF:落草稿/config/发布态 config、runtime run start / finish、score record、leaderboard entry、work stats projection、personal summary projection、`migration.rs` 与绑定生成。 -3. 最小前端纵切:接创作入口、单页轻配置表单、发布到稳定 workId、作品详情 CTA、runtime 拉 config、start / finish 串通、结算展示 `serverResult`。 +3. 最小前端纵切:接创作入口、创作 Tab 内嵌轻配置表单、发布到稳定 workId、作品详情 CTA、runtime 拉 config、start / finish 串通、结算展示 `serverResult`。 4. 投影与列表体验:接排行榜、个人历史最近记录 + 最佳摘要、作品统计、我的作品/广场卡片适配。 5. 收口验证:把 BDD 场景落到测试,执行编码检查、后端 `/healthz` + API smoke、前端人工验收路径,并更新 README/文档。 diff --git a/docs/technical/CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md b/docs/technical/CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md index 0676ecc6..0900004b 100644 --- a/docs/technical/CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md +++ b/docs/technical/CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md @@ -55,3 +55,4 @@ 3. 兜底背景底色跟随百梦浅粉、暖白和珊瑚色调,不能继续使用深黑或暗蓝渐变作为草稿卡默认视觉。 4. 拼图作品列表摘要必须下发 `levels`,草稿页优先用关卡 `coverImageSrc`,再用选中候选图或最后一张候选图作为真实作品封面兜底。 5. 抓大鹅作品列表摘要必须保留 `generatedBackgroundAsset` 与 `generatedItemAssets` 中的 `imageObjectKey`、`containerImageObjectKey` 和 `imageViews[].imageObjectKey`;前端拿到 object key 后统一交给 `ResolvedAssetImage` 换签,不能因为缺少公开 URL 而退回黑卡。 +6. `coverImageSrc` 若指向 `/creation-type-references/*`,只能视为玩法参考图兜底,不能当作作品真实封面。草稿页遇到这类值时必须继续向下解析同作品真实素材:拼图优先第一关正式图,再取选中候选图或最后一张候选图;抓大鹅优先 UI 背景图 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset.image*`,再取容器图,最后才取物品视角图或物品主图。 diff --git a/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md b/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md index 9d9d7f70..fb3d9dcd 100644 --- a/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md +++ b/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md @@ -27,6 +27,8 @@ 5. 生成成功后自动进入 `match3d-result`。 6. 生成失败时停留在生成过程页,允许重新生成或返回创作中心;重新生成必须复用同一个 session / profile,并从缺失的素材阶段继续,不新建第二份草稿。 +抓大鹅生成过程页不展示“当前批次”模块;移动端只保留“预计等待”和“计时”两张状态卡并排展示,步骤卡进入页面时按顺序从屏幕左侧滑入。 + 生成页步骤固定为: ```text @@ -52,9 +54,9 @@ 7. 后端从同一份作品生成计划读取当前难度所需数量的短物品名称,并兼容保存历史 `soundPrompt` 字段;当前不生成点击音效。 8. 调用 VectorEngine Gemini 原生图片接口生成 `1:1` 素材图,请求模型固定为 `gemini-3-pro-image-preview`,走 `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"]`、`generationConfig.imageConfig.aspectRatio = "1:1"`,响应从 `candidates[].content.parts[].inlineData.data` / `inline_data.data` 读取 base64 图片。提示词必须合入入口页选择的 `assetStylePrompt`,并强制每格使用统一纯绿色绿幕背景,避免白底或纹理背景进入运行态素材。该调整只作用于抓大鹅物品素材 sheet;封面和 `9:16` 纯背景图继续使用 VectorEngine `/v1/images/generations` 的 `gpt-image-2-all` JSON 链路,`1:1` 容器 UI 图必须使用 VectorEngine `/v1/images/edits` multipart 图生图链路,不能再把参考图作为 generations 的 `image` 数组弱参考。 9. 每个物品固定需要 `5` 个不同视角。单张素材图固定为 `5*5 = 25` 格,因此单张图承载 `5` 个物品。若用户要求或难度派生的物品种类不是 `5` 的倍数,后端必须向上补齐物品名称和对应图片到最近的 `5` 的倍数;例如标准难度需要 `9` 种玩法物品,实际生成 `10` 个物品名称和对应五视角图片。若草稿物品数超过 `5`,后端按每批 `5` 个物品自动分批,多张素材图并行生成。 -10. 将每张素材图按固定 `5 行 * 5 列` 切割成独立图片,并按物品顺序连续分配 `5` 张视角图。素材图提示词必须要求 `5*5` 严格均匀排布、每格主体完整居中、统一纯绿色绿幕背景、相邻物体主体至少保留 `1/4` 单格宽度空白间距、不得跨格、贴边或越界,避免裁剪后相邻格内容污染。切割前必须先在整张素材图上做透明背景后处理:连通到 sheet 外边缘的绿幕/近白底要清成 alpha;每格内部未连到外边缘但高置信的纯绿绿幕块也必须清成 alpha;物品边缘的绿幕抗锯齿和近白白边要做透明或去污染处理;不够纯的绿色主体像素不得被当作绿幕误删。随后再在每个理论格子内按透明背景/前景像素做内容边界校准,并带少量安全留白导出;不能做固定内缩裁剪,避免贴近格线但未跨格的樱桃、叶片、把手等主体边缘被切掉。每个物品 JSON 写入 `imageViews[]`,同时把第一个视角兼容写入 `imageSrc/imageObjectKey`。 +10. 将每张素材图按固定 `5 行 * 5 列` 切割成独立图片,并按物品顺序连续分配 `5` 张视角图。素材图提示词必须要求 `5*5` 严格均匀排布、每格主体完整居中、统一纯绿色绿幕背景、相邻物体主体至少保留 `1/4` 单格宽度空白间距、不得跨格、贴边或越界,避免裁剪后相邻格内容污染。切割前必须先在整张素材图上做透明背景后处理:连通到 sheet 外边缘的绿幕/近白底要清成 alpha;每格内部未连到外边缘但高置信的纯绿绿幕块也必须清成 alpha;物品边缘的绿幕抗锯齿和近白白边要做透明或去污染处理;较厚的半透明或混色软绿边必须沿整张 sheet 透明背景继续清理,不能先裁剪单格再各自去绿,否则裁剪图会残留绿色描边;不够纯的绿色主体像素不得被当作绿幕误删。随后再在每个理论格子内按透明背景/前景像素做内容边界校准,并带少量安全留白导出;不能做固定内缩裁剪,避免贴近格线但未跨格的樱桃、叶片、把手等主体边缘被切掉。每个物品 JSON 写入 `imageViews[]`,同时把第一个视角兼容写入 `imageSrc/imageObjectKey`。 11. 将素材图和每张独立视角图片上传到 OSS。每次获得可恢复的图片资产后,都要回写 `match3d_work_profile.generated_item_assets_json`。成功素材状态为 `image_ready`;失败素材保留已成功图片引用并记录 `error`。每个素材 JSON 可继续保存历史 `soundPrompt`;不再写入新的 `backgroundMusicTitle/backgroundMusicStyle/backgroundMusicPrompt`。 -12. UI 背景生成由 `api-server` 分成两张资产:第一张是 `9:16` 纯背景图,走 VectorEngine `/v1/images/generations` 的 `gpt-image-2-all` JSON 请求,不传锅参考图,且必须禁止锅、圆盘、托盘、拼图槽、物品槽、HUD、文字、按钮、倒计时、分数和物品;第二张是 `1:1` 题材容器 UI 图,走 VectorEngine `/v1/images/edits` multipart 请求,把 `public/match3d-background-references/pot-fused-reference.png` 作为 `image` part 上传,只生成一个贴合题材设定的圆形或浅盘状竞技容器,不生成整页背景、文字、按钮或物品。容器图必须沿用参考图的大尺寸轻俯视构图:外轮廓接近画布四边,宽度约占 `86%-92%`、高度约占 `82%-90%`,内口为横向椭圆,禁止生成小容器、正俯视圆盘、侧视碗、餐盘或小托盘。纯背景上传到 `generated-match3d-assets/{sessionId}/{profileId}/background/{taskId}/background.png`,容器 UI 图上传到 `generated-match3d-assets/{sessionId}/{profileId}/ui-container/{taskId}/container.png`,两者都作为 `backgroundAsset` 挂在首个 `generatedItemAssets[]` JSON 上;HTTP DTO 同时顶层输出兼容的 `backgroundPrompt`、`backgroundImageSrc`、`backgroundImageObjectKey` 与 `generatedBackgroundAsset`,容器图通过 `generatedBackgroundAsset.containerImageSrc/containerImageObjectKey` 返回。若作品尚无用户自定义封面,草稿生成完成后默认把容器 UI 图写入 `coverImageSrc`,作为草稿架和作品信息的默认封面。 +12. UI 背景生成由 `api-server` 分成两张资产:第一张是 `9:16` 纯背景图,走 VectorEngine `/v1/images/generations` 的 `gpt-image-2-all` JSON 请求,不传锅参考图,且必须禁止锅、圆盘、托盘、拼图槽、物品槽、HUD、文字、按钮、倒计时、分数和物品;第二张是 `1:1` 题材容器 UI 图,走 VectorEngine `/v1/images/edits` multipart 请求,把 `public/match3d-background-references/pot-fused-reference.png` 作为 `image` part 上传,只生成一个贴合题材设定的圆形或浅盘状竞技容器,不生成整页背景、文字、按钮或物品。容器图必须沿用参考图的大尺寸轻俯视构图:外轮廓接近画布四边,宽度约占 `86%-92%`、高度约占 `82%-90%`,内口为横向椭圆,禁止生成小容器、正俯视圆盘、侧视碗、餐盘或小托盘。容器图入库前必须统一转成带透明 alpha 的 PNG;若上游返回白底、浅色底或抗锯齿底色,`api-server` 在上传 OSS 前清成透明背景。纯背景上传到 `generated-match3d-assets/{sessionId}/{profileId}/background/{taskId}/background.png`,容器 UI 图上传到 `generated-match3d-assets/{sessionId}/{profileId}/ui-container/{taskId}/container.png`,两者都作为 `backgroundAsset` 挂在首个 `generatedItemAssets[]` JSON 上;HTTP DTO 同时顶层输出兼容的 `backgroundPrompt`、`backgroundImageSrc`、`backgroundImageObjectKey` 与 `generatedBackgroundAsset`,容器图通过 `generatedBackgroundAsset.containerImageSrc/containerImageObjectKey` 返回。若作品尚无用户自定义封面,草稿生成完成后默认把容器 UI 图写入 `coverImageSrc`,作为草稿架和作品信息的默认封面;草稿架封面解析也应优先使用容器图,其次才是纯背景图和物品图,完全缺失生成图时使用透明参考容器图兜底。 13. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息、背景资产信息和默认封面;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 与 `coverImageSrc` 恢复同一批素材、UI 与封面。历史音频字段只做兼容传递。 若文本模型不可用或返回无法解析,后端必须降级为 `{themeText}抓大鹅`、本地作品描述与本地标签兜底,不阻断素材生成;标签仍通过作品标签生成器优先生成,失败后再用兜底标签补齐。 @@ -100,7 +102,7 @@ public/match3d-style-references/painterly-icon.png public/match3d-background-references/pot-fused-reference.png ``` -这张图只作为容器 UI 图的 VectorEngine `/v1/images/edits` multipart `image` part,用来锁定“大尺寸轻俯视浅盘容器”的构图。参考图本身是 `1:1` 透明底容器素材,外轮廓接近画布四边,内口为横向椭圆;结果页没有真实生成容器时也只把它作为容器预览兜底,不能再作为 `9:16` 背景预览。每次草稿生成仍会根据 `backgroundPrompt` 生成新的题材化纯背景图;纯背景图不再传入该参考图,也不得生成锅或 UI 元素。 +这张图只作为容器 UI 图的 VectorEngine `/v1/images/edits` multipart `image` part,用来锁定“大尺寸轻俯视浅盘容器”的构图。参考图本身是 `1:1` 透明底容器素材,外轮廓接近画布四边,内口为横向椭圆;结果页、草稿预览和运行态没有真实生成容器时都把它作为容器兜底,不能再作为 `9:16` 背景预览。每次草稿生成仍会根据 `backgroundPrompt` 生成新的题材化纯背景图;纯背景图不再传入该参考图,也不得生成锅或 UI 元素。 ## 5. OSS 路径 @@ -140,7 +142,7 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard 运行态按运行快照中的 `itemTypeId` 稳定排序后,把 `generatedItemAssets` 顺序映射到对应类型。加载某个物品实例时,从该类型素材的 `imageViews[]` 中按实例 id 稳定随机选择一个视角;若历史数据没有 `imageViews[]`,则回退到 `imageSrc/imageObjectKey`。没有生成图片或图片加载失败时,继续使用默认积木图标兜底。 -运行态背景优先读取 `backgroundImageSrc` / 顶层 `generatedBackgroundAsset.imageSrc/imageObjectKey`,为空时从 `generatedItemAssets[].backgroundAsset.imageSrc/imageObjectKey` 兜底。中心容器优先读取顶层 `generatedBackgroundAsset.containerImageSrc/containerImageObjectKey`,再读取 `generatedItemAssets[].backgroundAsset.containerImageSrc/containerImageObjectKey`;为空或换签/图片加载失败时继续使用默认圆形容器样式。容器图成功加载后,`Match3DRuntimeShell` 的棋盘容器必须切换为透明、可溢出承载,不再叠加默认 `rounded-full` 圆形锅壳、金色边框和默认径向背景,避免 AI 生成的大尺寸轻俯视容器被裁切或被默认锅视觉覆盖。运行态入口判断是否需要补读作品详情时,只能把 `imageViews[]` 或 `imageSrc/imageObjectKey` 视为“已有物品图片素材”;`backgroundMusic.audioSrc`、`clickSound.audioSrc`、`generatedBackgroundAsset`、`backgroundAsset.image*` 和 `backgroundAsset.containerImage*` 是随物品素材一起传入的附属运行态资产,不能单独证明物品素材已完整。也不能继续只用历史 `modelSrc/modelObjectKey` 判断,否则新 2D 草稿会在试玩或推荐流中被当成“无素材”并回退默认积木。`Match3DRuntimeShell` 只保留顶部返回、倒计时、重开三个控件;这些顶部控件和底部备选栏统一使用题材无关的半透明玻璃组件样式,不能随背景题材改成木质、金属、果园、科幻等主题皮肤,也不能重新烘进 AI 背景图。进度、组数、版本等状态信息不得再作为顶部常驻 UI 出现,避免遮挡生成背景和中心容器。 +运行态背景优先读取 `backgroundImageSrc` / 顶层 `generatedBackgroundAsset.imageSrc/imageObjectKey`,为空时从 `generatedItemAssets[].backgroundAsset.imageSrc/imageObjectKey` 兜底。中心容器优先读取顶层 `generatedBackgroundAsset.containerImageSrc/containerImageObjectKey`,再读取 `generatedItemAssets[].backgroundAsset.containerImageSrc/containerImageObjectKey`;为空或换签/图片加载失败时使用 `public/match3d-background-references/pot-fused-reference.png` 作为透明容器图兜底,不再回退到默认圆形锅壳。容器图成功加载后,`Match3DRuntimeShell` 的棋盘容器必须切换为透明、可溢出承载,不再叠加默认 `rounded-full` 圆形锅壳、金色边框和默认径向背景,避免 AI 生成的大尺寸轻俯视容器被裁切或被默认锅视觉覆盖。移动端棋盘宽度应接近屏幕宽度并居中,容器图片允许略超出棋盘承载盒以保留大尺寸浅盘轮廓。运行态入口判断是否需要补读作品详情时,只能把 `imageViews[]` 或 `imageSrc/imageObjectKey` 视为“已有物品图片素材”;`backgroundMusic.audioSrc`、`clickSound.audioSrc`、`generatedBackgroundAsset`、`backgroundAsset.image*` 和 `backgroundAsset.containerImage*` 是随物品素材一起传入的附属运行态资产,不能单独证明物品素材已完整。也不能继续只用历史 `modelSrc/modelObjectKey` 判断,否则新 2D 草稿会在试玩或推荐流中被当成“无素材”并回退默认积木。`Match3DRuntimeShell` 只保留顶部返回、倒计时、重开三个控件;这些顶部控件和底部备选栏统一使用题材无关的半透明玻璃组件样式,不能随背景题材改成木质、金属、果园、科幻等主题皮肤,也不能重新烘进 AI 背景图。进度、组数、版本等状态信息不得再作为顶部常驻 UI 出现,避免遮挡生成背景和中心容器。 前端加载规则: @@ -184,7 +186,7 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard 1. `作品名称` 对应 Match3D `gameName`。 2. `作品描述` 对应 Match3D `summary`,草稿生成阶段由同一次作品生成计划自动填入。 3. `作品标签` 对应 Match3D `tags`,草稿生成阶段在写入名称和描述后自动调用标签生成器填入;结果页仍允许用户继续编辑或再次 AI 生成。 -4. 封面图与作品名称不再拆成左右两个大模块;封面只作为同一 Tab 内的可选入口,避免和作品基础信息割裂。旧称“碰面图”统一改为“封面图”。草稿生成默认使用生成出的中心容器 UI 图作为 `coverImageSrc`。点击封面图必须弹出独立编辑面板,不允许在当前作品信息面板下方展开。封面面板布局对齐拼图创作页上传卡:移动端优先,左侧/上方为方形预览卡,预览卡本身就是上传热区;上传图片后,预览卡内出现和拼图入口一致的 `AI重绘` 开关与删除按钮,面板底部不再额外展示旧 `AI重绘` 选项。已有上传图时,右侧/下方输入框标题为 `AI重绘要求`;关闭 AI 重绘时只把上传图 Data URL 写入封面字段,不调用生图模型。没有上传图时,输入框标题为 `封面描述`,可选择多张参考图后调用 VectorEngine `gpt-image-2-all` 文生图链路,参考图通过请求体 `image` 数组传入;参考图来源支持直接引用 `物品素材` / `UI素材` 中已有图片,也支持自定义上传。上传图 AI 重绘与无上传图多参考图生成都通过 `api-server` 的 Match3D 作品封面生成接口完成,生成结果转存到 `generated-match3d-assets/{sessionId}/{profileId}/cover/{taskId}/cover.png` 后再写回 `coverImageSrc`。 +4. 封面图修改归入发布面板,不再作为 `作品信息` Tab 内的独立入口。结果页底部 `发布` 按钮对齐拼图:非忙碌态始终可点击,点击后打开独立 `发布抓大鹅作品` 面板;面板上方集中展示发布检查和阻断项,面板内承载封面编辑,满足门槛后再点击 `发布到广场`。旧称“碰面图”统一改为“封面图”。草稿生成默认使用生成出的中心容器 UI 图作为 `coverImageSrc`。封面编辑布局对齐拼图创作页上传卡:移动端优先,左侧/上方为方形预览卡,预览卡本身就是上传热区;上传图片后,预览卡内出现和拼图入口一致的 `AI重绘` 开关与删除按钮,面板底部不再额外展示旧 `AI重绘` 选项。已有上传图时,右侧/下方输入框标题为 `AI重绘要求`;关闭 AI 重绘时只把上传图 Data URL 写入封面字段,不调用生图模型。没有上传图时,输入框标题为 `封面描述`,可选择多张参考图后调用 VectorEngine `gpt-image-2-all` 文生图链路,参考图通过请求体 `image` 数组传入;参考图来源支持直接引用 `物品素材` / `UI素材` 中已有图片,也支持自定义上传。上传图 AI 重绘与无上传图多参考图生成都通过 `api-server` 的 Match3D 作品封面生成接口完成,生成结果转存到 `generated-match3d-assets/{sessionId}/{profileId}/cover/{taskId}/cover.png` 后只写回 `coverImageSrc` / `coverAssetId` 相关字段。封面生成或上传后发布面板保持打开,方便继续完成发布。封面生成接口不得复用草稿编译流程,不得重算题材、难度、消除次数或 `generated_item_assets_json`;前端收到封面生成回包时也只能把 `coverImageSrc` 合并到当前结果页 profile,不能用回包中的旧 `generatedItemAssets`、`clearCount` 或 `difficulty` 覆盖当前页面状态。 结果页 `难度配置` Tab 取代旧 `玩法配置`,不再展示旧的分散输入项。该 Tab 顶部使用横向离散拖动条调整难度,四个刻度分别为 `轻松 / 标准 / 进阶 / 硬核`;拖动条只能落在这四个点上,刻度标签可点击切换。该 Tab 必须与创作入口页使用同一组难度选项,并统一把原“类型素材图片 / 局内类型”等口径归一为 `物品种类`: @@ -211,7 +213,7 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard 2. 素材名称输入。 3. 不展示点击音效提示词输入或点击音效生成入口。 -五视角预览区采用“上方大预览 + 底部缩略图栏”的布局:上方是方形焦点预览区,中间横向排列当前物品的各视角图片,并用内框标出当前焦点;底部缩略图栏固定露出 `4` 个方形槽位,多出的第 `5` 个视角通过横向滚动访问。点击缩略图只切换焦点视角,不在面板内新增说明文案或额外规则区。 +五视角预览区采用“上方大预览 + 底部缩略图栏”的布局:上方方形焦点预览区只显示当前选中的单张大图,用于详细查看物品形象,图片在方格内放大显示,不再渲染素材自带缩略图框、焦点内框或横向五图带;底部缩略图栏固定露出 `4` 个方形槽位,多出的第 `5` 个视角通过横向滚动访问。点击缩略图只切换焦点视角,不在面板内新增说明文案或额外规则区。 详情页不再展示参考图、用途、模型提示词、文生/图生切换、状态查询、下载列表、taskUuid 或 subscriptionKey。 diff --git a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md index 2ac13215..fcaebcf5 100644 --- a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md +++ b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md @@ -29,7 +29,7 @@ | AIRP | 是 | 否 | 保留入口,显示敬请期待 | | 视觉小说 | 是 | 否 | 保留入口,显示敬请期待,暂不允许创建视觉小说草稿 | | 智能创作 | 否 | 是 | 入口隐藏,既有 `creative-agent` 链路保留 | -| 汪汪声浪 | 是 | 是 | `bark-battle` 正式轻创作入口,进入单页配置表单并发布后启动声控对战 runtime | +| 汪汪声浪 | 是 | 是 | `bark-battle` 正式轻创作入口,选中模板后直接在创作 Tab 内嵌轻配置表单,发布后启动声控对战 runtime | | 宝贝识物 | 是 | 是 | 寓教于乐首关模板,必须由 `creation_entry_type_config` 默认种子或后台入口开关保持存在 | ## 排障约束 @@ -53,5 +53,6 @@ 3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。 4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。 5. 创作 Tab 首屏应显示“10分钟创作一个精品互动玩法”,并默认展示拼图创作表单。 -6. 智能创作入口隐藏后,不应出现“Hi, 朋友”“问一问陶泥儿”或“一句话生成闪应用”等旧首页入口。 -7. 方洞挑战入口隐藏后,不应出现在创作 Tab 模板入口、创作中心顶部卡带、平台创作类型弹层和创作页作品架中;既有 `SH-` 作品号、广场详情和试玩 runtime 链路不因此删除。 +6. 汪汪声浪、拼图和抓大鹅都应在创作 Tab 的模板条下方内嵌展示对应创作表单;汪汪声浪不得再跳到独立配置阶段或弹出整页配置页面。 +7. 智能创作入口隐藏后,不应出现“Hi, 朋友”“问一问陶泥儿”或“一句话生成闪应用”等旧首页入口。 +8. 方洞挑战入口隐藏后,不应出现在创作 Tab 模板入口、创作中心顶部卡带、平台创作类型弹层和创作页作品架中;既有 `SH-` 作品号、广场详情和试玩 runtime 链路不因此删除。 diff --git a/docs/technical/PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md b/docs/technical/PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md index 946e2957..6044edd4 100644 --- a/docs/technical/PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md +++ b/docs/technical/PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md @@ -11,6 +11,7 @@ 3. 推荐页运行态画面保持独立可交互区域,不挂平台切换作品的 pointer 手势。 4. 切换作品的纵向手势只绑定在卡片底部作品信息区;底部信息区可以扩大触控高度,但不得绝对定位覆盖运行态画面。 5. 点赞、分享、改造按钮继续阻止 pointer 事件冒泡,避免按钮点击误触发切换作品。 +6. 作品信息区默认只保留紧凑一行身份和一组操作按钮,底部热区不再占用过高固定高度,避免挤压运行态画面。 ## 验收 @@ -19,3 +20,4 @@ 3. 在作品运行态画面内点击、拖拽或滑动,只触发作品自身交互。 4. 在底部作品信息区上下滑动,可以切换推荐作品。 5. 点赞、分享、改造按钮可正常点击,不触发作品切换。 +6. 推荐卡片的视觉主体高度应明显高于底部信息区,且信息区不应明显压缩首屏运行态。 diff --git a/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md b/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md index 4edff6ab..c0362272 100644 --- a/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md +++ b/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md @@ -66,7 +66,7 @@ ```text VECTOR_ENGINE_BASE_URL="https://api.vectorengine.ai" VECTOR_ENGINE_API_KEY="YOUR_VECTOR_ENGINE_API_KEY" -VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000 +VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000 ``` `VECTOR_ENGINE_API_KEY` 只能存在于本地或部署环境,不写入 Git 跟踪文件。若缺少 key,后端返回服务不可用错误,前端展示现有错误面板。 diff --git a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md index 41173f5a..7a3e1c2c 100644 --- a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md +++ b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md @@ -4,7 +4,7 @@ 拼图创作入口不再使用 Agent 对话收集题材锚点。新流程让玩家填写作品名称、作品描述、画面描述三类信息,其中画面描述只服务首关画面生成与关卡画面语义,不再作为作品详情页的作品描述。画面描述支持上传参考图。玩家确认后直接进入草稿生成进度页,后续草稿生成、首图生成、正式图选择、结果页编辑和发布沿用现有后端编排。 -2026-05-03 后入口进一步收口为画面描述直创:入口表单只保留画面描述、参考图和图片模型选择;作品名称、作品描述、作品标签全部进入结果页补全。若本文件早期段落仍提到入口必填作品名称或作品描述,以 `PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md` 为准。 +2026-05-03 后入口进一步收口为画面描述直创:入口表单只保留画面描述、参考图和图片模型选择;作品名称、作品描述、作品标签由草稿生成阶段默认补齐,并继续允许玩家在结果页编辑。若本文件早期段落仍提到入口必填作品名称或作品描述,以 `PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md` 为准。 ## 首访新手引导隐藏 @@ -18,9 +18,13 @@ 1. 入口表单只展示 `画面描述`、参考图和图片模型选择;`画面描述` 是唯一必填字段。 2. 表单自动保存只保存 `pictureDescription`,不再保存入口作品名称、作品描述或推断标签。 -3. 点击“生成草稿”后进入生成进度页,步骤固定对齐后端当前编排:“编译首关草稿 -> 生成关卡名称与 UI 背景提示词 / 生成首关画面 -> 生成UI背景 -> 写入正式草稿”。其中关卡名称文本生成、UI 背景提示词生成与首关画面生成可并行;首关最终名称确定后生成 UI 背景。背景音乐生成已于 2026-05-14 临时关闭。 +3. 点击“生成草稿”后进入生成进度页,步骤固定对齐后端当前编排:“编译首关草稿 -> 生成关卡名称、作品描述、6 个作品标签与 UI 背景提示词 / 生成首关画面 -> 生成UI背景 -> 写入正式草稿”。其中关卡名称文本生成、作品元信息生成、UI 背景提示词生成与首关画面生成可并行;首关最终名称确定后生成 UI 背景。背景音乐生成已于 2026-05-14 临时关闭。 4. 生成进度页“当前拼图信息”只展示画面描述;不得展示空作品名称、空作品描述或旧五锚点结构。 -5. 结果页打开后,作品名称默认使用首关名称,作品描述与作品标签保持为空,等待用户在作品信息 Tab 补全或触发 AI 标签生成。 +5. 生成进度页不展示“当前批次”模块;移动端只保留“预计等待”和“计时”两张状态卡并排展示,步骤卡进入页面时按顺序从屏幕左侧滑入。 +6. 结果页打开后,作品名称默认使用首关名称,作品描述默认使用首关命名请求返回的 `workDescription`,作品标签默认使用同次请求返回的 6 个 `workTags`。若模型不可用或返回非法字段,才保留空描述 / 空标签,等待用户在作品信息 Tab 补全或触发 AI 标签生成。 +7. 入口存在两类图片输入:左侧 `拼图画面` 是主图上传入口,仍按现有正方形裁剪和 `aiRedraw` 逻辑生成或直接使用;当未上传主图时,`画面描述` 输入框右下角展示 `上传参考图` icon,可一次选择多张参考图,最多 5 张。 +8. 参考图上传后只以小缩略图展示在画面描述框下方,点击缩略图放大预览;每张缩略图可单独移除。上传主图后隐藏这组描述参考图,避免主图与参考图职责混淆。 +9. 前端提交 `referenceImageSrcs` 数组;api-server 兼容旧 `referenceImageSrc` 单图字段,并把旧单图和新数组去重后最多保留 5 张。当前拼图 VectorEngine 图生图链路仍只使用第一张有效参考图作为实际编辑参考图,数组字段用于入口体验和后续多参考图生成能力扩展。 ### 2026-04-30 初始表单草稿保存补充 @@ -90,18 +94,19 @@ 12. `compile_puzzle_draft` 中的图片上游失败不得映射成 `400 BAD_REQUEST`。DashScope 返回 `InvalidParameter` 或任务失败时,api-server 统一按 `502 UPSTREAM_ERROR` 暴露,并在 `details.message` 中保留“拼图图片生成失败:...”的业务原因,避免生成页只显示“请求参数不合法”。 13. `compile_puzzle_draft` 前置泥点预扣失败不得映射成 `400 BAD_REQUEST`。余额不足返回 `409 CONFLICT`,SpacetimeDB procedure 不可用、绑定不匹配、钱包服务异常等统一按 `502 UPSTREAM_ERROR` 暴露,并在 `details.message` 中保留真实钱包错误。 14. 生成拼图作品草稿动作涉及的表单 seed prompt 与首图 prompt 来源选择统一收口在 `server-rs/crates/api-server/src/prompt/puzzle/draft.rs`;`puzzle.rs` 只负责调用 SpacetimeDB、计费、图片服务和持久化,不再直接拼草稿 prompt 文本。 -15. `compile_puzzle_draft_with_initial_cover` 中,首关文本名称生成与首关图片生成互不等待:首图 prompt 只读取画面描述,OSS 临时路径使用已有名或确定性兜底名;同一次首关命名 LLM 请求必须返回 `levelName` 与 `uiBackgroundPrompt`,首图返回后再用图片语义尝试精修最终关卡名与 UI 背景提示词。最终关卡名确定后,必须继续用 AI 返回的 `uiBackgroundPrompt` 生成首关 UI 背景图;若命名模型未返回可用提示词,才按作品、关卡和标签拼接确定性兜底提示词。写入正式草稿前校验 `levels[0].uiBackgroundImageSrc/uiBackgroundImageObjectKey` 不为空;UI 背景失败时 `compile_puzzle_draft` 返回上游错误,生成页停留失败态。背景音乐生成临时关闭,不再作为草稿完成门槛。 +15. `compile_puzzle_draft_with_initial_cover` 中,首关文本名称生成与首关图片生成互不等待:首图 prompt 只读取画面描述,OSS 临时路径使用已有名或确定性兜底名;同一次首关命名 LLM 请求必须返回 `levelName`、`workDescription`、6 个 `workTags` 与 `uiBackgroundPrompt`,首图返回后再用图片语义尝试精修最终关卡名、作品元信息与 UI 背景提示词。解析层必须拒绝 `levelNam`、`levelName` 这类字段名片段被当成关卡名。最终关卡名确定后,必须继续用 AI 返回的 `uiBackgroundPrompt` 生成首关 UI 背景图;若命名模型未返回可用提示词,才按作品、关卡和标签拼接确定性兜底提示词。写入正式草稿前校验 `levels[0].uiBackgroundImageSrc/uiBackgroundImageObjectKey` 不为空;UI 背景失败时 `compile_puzzle_draft` 返回上游错误,生成页停留失败态。背景音乐生成临时关闭,不再作为草稿完成门槛。 16. `compile_puzzle_draft_with_uploaded_cover` 中,上传图解析后,文本名称生成、图片语义名称生成和上传图转存 OSS 可并行;上传图转存失败必须立即返回,不得继续触发 UI 背景生成。上传图转存成功且最终关卡名确定后,同样必须生成并校验首关 UI 背景图。自动草稿阶段不再触发音乐资产生成。 +17. `CreatePuzzleAgentSessionRequest` 与 `ExecutePuzzleAgentActionRequest` 兼容新增 `referenceImageSrcs: string[]`。`aiRedraw = true` 且未上传主图时,api-server 从 `referenceImageSrc` 与 `referenceImageSrcs` 中按顺序取第一张有效参考图进入现有图生图分支;`aiRedraw = false` 时仍必须依赖 `referenceImageSrc` 主图,不能用描述参考图绕过主图上传。 ## 结果页 拼图草稿结果页分为三个一级 Tab: -1. 拼图关卡列表:默认展示草稿生成出的第一关。列表项参考 RPG 草稿卡片样式,显示画面图、关卡名称和轻量状态。支持新增关卡、删除关卡。点击列表项进入独立关卡详情页,不在列表项下方展开。关卡详情页可编辑关卡名称、画面描述、生成或重新生成画面,并在已有正式图后支持关卡测试。 -2. 作品信息:展示并编辑作品名称、作品描述、作品标签。 +1. 作品信息:默认打开,展示并编辑作品名称、作品描述、作品标签。 +2. 拼图关卡列表:展示草稿生成出的第一关。列表项参考 RPG 草稿卡片样式,显示画面图、关卡名称和轻量状态。支持新增关卡、删除关卡。点击列表项进入独立关卡详情页,不在列表项下方展开。关卡详情页可编辑关卡名称、画面描述、生成或重新生成画面,并在已有正式图后支持关卡测试。 3. 素材配置:对齐抓大鹅草稿页结构,当前只包含 `UI` 子 Tab;`背景音乐` 子 Tab 已临时隐藏。 -`素材配置 > UI` 展示并编辑拼图运行态 UI 背景提示词。`compile_puzzle_draft` 草稿编译完成首图后,`api-server` 会优先使用首关命名 LLM 同次返回的 `uiBackgroundPrompt` 自动生成首关 9:16 纯背景图;只有模型未返回可用提示词时,才基于作品名称、作品描述、标签和首关信息拼接兜底提示词。结果页继续支持用户修改提示词并通过 `generate_puzzle_ui_background` 重新生成。图片生成调用 VectorEngine `gpt-image-2-all` 的 `9:16` 图片生成链路。生成结果写入首关 `levels_json` 的 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`,不新增 SpacetimeDB 表字段。 +`素材配置 > UI` 展示并编辑拼图运行态 UI 背景提示词。`compile_puzzle_draft` 草稿编译完成首图后,`api-server` 会优先使用首关命名 LLM 同次返回的 `uiBackgroundPrompt` 自动生成首关 9:16 纯背景图,并用同次返回的 `workDescription` 与 `workTags` 默认填充作品信息。只有模型未返回可用提示词时,才基于作品名称、作品描述、标签和首关信息拼接兜底提示词。结果页继续支持用户修改提示词并通过 `generate_puzzle_ui_background` 重新生成。图片生成调用 VectorEngine `gpt-image-2-all` 的 `9:16` 图片生成链路。生成结果写入首关 `levels_json` 的 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`,不新增 SpacetimeDB 表字段。 历史 `levels_json[0].backgroundMusic` 字段继续兼容读取和运行态播放,但结果页暂不提供编辑或生成入口。拼图结果页不再保留一级 `UI` 或一级 `音乐` Tab。 @@ -125,6 +130,12 @@ 3. 自动试玩只在当前仍处于 `puzzle-generating` 时触发;若玩家已返回草稿 Tab 或切到其它页面,后台生成完成只标记草稿已生成,不得强行抢屏进入试玩。 4. 若自动启动试玩失败,前端保留草稿结果页作为兜底查看入口,并展示已有错误态,不应丢失已生成草稿。 +### 2026-05-14 运行态合并块拖拽补充 + +1. 合并块拖拽时,真实视觉只由 `mergedGroups` 生成的整体绝对层承载;棋盘格里的单块 DOM 只作为透明定位占位。 +2. 合并格即使在 `pointerdown` 后同步写入了 `selectedPieceId`,也不得应用单块选中填充色,否则整体合并块被拖起后会在原位置露出粉红 / 红色底块。 +3. 单块未合并时仍保留选中态;合并格样式优先级必须高于选中态,回归测试覆盖 `拖拽合并大块时底层单格不显示选中色块`。 + ### 2026-04-30 关卡列表卡片交互补充 1. 关卡列表卡片的删除按钮与关卡名称放在同一信息行,按钮固定在卡片右下角;不得再单独占用一整条底部分隔栏。 @@ -161,7 +172,7 @@ 1. 从拼图创作入口只能看到作品名称、作品描述、画面描述和参考图上传,不出现 Agent 聊天输入、补齐设定、锚点问答。 2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择和首关 UI 背景图生成。 3. 首图生成请求使用玩家画面描述作为 prompt;上传参考图时走图生图;作品详情页展示玩家作品描述。 -4. 结果页包含“拼图关卡”“作品信息”“素材配置”三个一级 Tab;`素材配置` 内当前只包含 `UI` 子 Tab,不展示背景音乐生成入口。关卡列表默认至少一关,支持新增、删除和进入关卡详情。 +4. 结果页一级 Tab 顺序为“作品信息”“拼图关卡”“素材配置”,默认打开“作品信息”;`素材配置` 内当前只包含 `UI` 子 Tab,不展示背景音乐生成入口。关卡列表默认至少一关,支持新增、删除和进入关卡详情。 5. 关卡详情页支持生成或重新生成画面;已有正式图后显示吸底“关卡测试”入口。 6. 发布、作品测试、自动保存作品名称、作品描述、作品标签和关卡列表仍可用。 7. 草稿初次生成后首关默认带 `uiBackgroundImageSrc`;若后端只返回 `uiBackgroundImageObjectKey` 也必须能在结果页、试玩和运行态正常预览;UI Tab 可修改提示词并重新生成背景图;生成后运行态应显示 `uiBackgroundImageSrc` 或换签后的 `uiBackgroundImageObjectKey`,拼图槽位和棋盘边界仍由默认运行态样式绘制。 diff --git a/docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md b/docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md index 714bc2d3..039292c7 100644 --- a/docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md +++ b/docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md @@ -2,7 +2,7 @@ 日期:`2026-04-27` -更新:`2026-05-08` +更新:`2026-05-14` ## 背景 @@ -20,6 +20,7 @@ 4. `refreshKey` 只能用于跳过前端签名 URL 缓存并重新请求 `/api/assets/read-url`,不能再给 OSS V4 签名 URL 追加 `_v` 等额外 query;OSS 会把 query 纳入签名,额外参数会让新生成图变成 403/破图。 5. 历史素材被选为参考图后,参考图小预览也必须走 `ResolvedAssetImage`,不能使用裸 ``。 6. 禁止恢复 `/generated-puzzle-assets/{*path}` Axum 路由或 Vite 直读代理;正式读取统一走 `/api/assets/read-url`。 +7. 历史素材卡片的标题和选中标签只从 `imageSrc` 解析图片文件名;`ownerLabel` 是账号归属信息,不能作为图片名展示。`createdAt` 需要兼容 `1713686400.000000Z` 这类 SpacetimeDB 时间字符串。 ## 后续约束 diff --git a/docs/technical/README.md b/docs/technical/README.md index db0b8520..6170c69e 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,12 +4,14 @@ ## 文档列表 +- [【前端体验】图像组件统一封装与复用边界-2026-05-14.md](./【前端体验】图像组件统一封装与复用边界-2026-05-14.md):冻结创作页统一图像输入面板 `CreativeImageInputPanel` 的受控边界、主图上传、画面描述、多参考图、AI 重绘开关、预览和提交口径,供拼图、抓大鹅封面和后续创作页复用。 - [WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md](./WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md):记录微信小程序 `web-view` 壳的最小接入范围、需要填写的 H5 业务域名、微信后台配置、`npm run check:wechat-miniprogram-auth` 可重复登录链路 smoke 和后续原生化边界。 - [BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md](./BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md):冻结寓教于乐 `宝贝爱画` 独立本地 Demo 运行态实现方案,明确发现页默认卡片、`/runtime/baby-love-drawing` 路由、画板交互、mocap/键鼠调试映射、本地保存和 VectorEngine image-2 绘画魔法后端代理。 - [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。 - [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。 - [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。 - [PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md](./PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md):冻结移动端推荐页隐藏顶部品牌栏、扩大推荐卡片可用高度,以及只在底部作品信息区承接切换作品手势的布局口径。 +- [【前端体验】移动端输入法不压缩画布聚焦方案-2026-05-14.md](./【前端体验】移动端输入法不压缩画布聚焦方案-2026-05-14.md):记录移动端输入法弹出时平台画布保持稳定高度,只通过画面位移聚焦当前输入框的实现口径。 - [BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md](./BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md):冻结寓教于乐 `宝贝识物` 模板创作发布线程的前端入口、契约、service、结果页、发布标签和后端 image-2 接口预留边界。 - [CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md):冻结儿童动作识别互动玩法 Demo 固定热身关的开发落地规格,覆盖横屏展示、摄像头背景虚化、角色剪影、绿色圆环 2 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。 - [RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md](./RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md):记录运行态输入设备抽象层,明确鼠标、触控、mocap 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。 diff --git a/docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md b/docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md index 39d220f8..3f0b7294 100644 --- a/docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md +++ b/docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md @@ -60,7 +60,7 @@ model = gpt-image-2-all ```text VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai VECTOR_ENGINE_API_KEY=... -VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000 +VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000 ``` `VECTOR_ENGINE_API_KEY` 缺失时,角色主图与场景图返回 `SERVICE_UNAVAILABLE`,`details.provider = "vector-engine"`。 diff --git a/docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md b/docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md index a405c1f9..11309235 100644 --- a/docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md +++ b/docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md @@ -89,7 +89,7 @@ VectorEngine 文档要求使用像素尺寸,不再使用 APIMart 的比例写 ```text VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai VECTOR_ENGINE_API_KEY= -VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000 +VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000 ``` 说明: diff --git a/docs/technical/【前端体验】图像组件统一封装与复用边界-2026-05-14.md b/docs/technical/【前端体验】图像组件统一封装与复用边界-2026-05-14.md new file mode 100644 index 00000000..59d07556 --- /dev/null +++ b/docs/technical/【前端体验】图像组件统一封装与复用边界-2026-05-14.md @@ -0,0 +1,32 @@ +# 图像组件统一封装与复用边界 2026-05-14 + +## 背景 + +拼图创作页、抓大鹅封面页以及后续更多创作页,都需要同一套图像输入能力:未上传图片时可用画面描述生成图片,也可叠加多张参考图;上传图片后可选择重绘或直接使用当前图片。以前这些能力分散在各页内联实现,重复了上传、预览、移除、参考图展示和文案布局。 + +## 组件边界 + +1. 图像组件负责统一承载图片上传卡、画面描述输入、参考图入口、参考图缩略图、参考图预览、AI 重绘开关和提交按钮。 +2. 图像组件只做交互与表现层,不决定具体生图接口,也不直接耦合某个玩法的草稿提交逻辑、计费逻辑或历史素材查询逻辑。 +3. 主图读取、裁剪、参考图转 Data URL、历史素材选择、生成请求提交、自动保存和后端契约透传仍由外层页面负责。 +4. 图像组件采用受控模式,外层页面传入当前图片、画面描述、参考图数组、AI 重绘状态和图片模型等业务真相,组件只回调变更与提交动作。 + +## 统一交互口径 + +1. 未上传主图时,画面描述输入框右下角显示参考图上传 icon,允许多选,默认最多 5 张。 +2. 参考图只以小缩略图展示,点击缩略图可放大预览,单张可移除。 +3. 主图上传后隐藏这组画面描述参考图入口,避免主图与参考图职责混淆。 +4. 主图存在时,组件显示 AI 重绘开关;关闭后只保留当前图片直接提交的路径,不再展示画面描述输入。 +5. 组件只负责把提交按钮、错误提示、预览弹层和确认弹层组织成统一 UI,具体按钮文案和费用提示由外层页面传入。 + +## 复用约定 + +1. 拼图入口先接入该组件,保留画面描述直生图、参考图生图、上传图重绘和不重绘四种路径。 +2. 后续抓大鹅封面、其他创作页和模板页复用时,只替换外层的提交动作、图片模型选择和外部错误来源。 +3. 如未来某页不需要历史素材入口、参考图入口或图片模型选择,应通过 props 关闭,不要复制一套新的图像输入实现。 + +## 验收 + +1. 拼图创作页的图像输入模块改为独立组件后,行为与原有页面一致。 +2. 参考图多选、缩略图预览、移除、主图上传、AI 重绘开关和提交按钮行为不退化。 +3. 组件可以在不改内部实现的情况下接入其它页面,只替换外层业务回调和文案。 diff --git a/docs/technical/【前端体验】移动端输入法不压缩画布聚焦方案-2026-05-14.md b/docs/technical/【前端体验】移动端输入法不压缩画布聚焦方案-2026-05-14.md new file mode 100644 index 00000000..45216493 --- /dev/null +++ b/docs/technical/【前端体验】移动端输入法不压缩画布聚焦方案-2026-05-14.md @@ -0,0 +1,22 @@ +# 移动端输入法不压缩画布聚焦方案 + +## 背景 + +平台主站采用游戏式固定画布体验,根壳原本优先使用 `100dvh`。手机浏览器弹出输入法时,`dvh` 会跟随可见视口变小,导致上方页面、推荐卡、创作首页和底部输入区一起被压缩,画面层级失真。 + +用户预期是:点击输入框后,画面本身不要重新压缩排版,只把当前画面位置向输入框聚焦,让输入框避开输入法。 + +## 落地口径 + +1. 主站启动时统一调用 `stabilizeMobileViewportKeyboardFocus()`,只在触控或粗指针设备上启用。 +2. `.platform-viewport-shell` 的高度优先读取 `--platform-layout-viewport-height`,该值在输入法未打开时记录稳定布局高度;输入法打开期间不跟随 `visualViewport.height` 缩小。 +3. 输入框聚焦且 `visualViewport` 明显变小时,计算当前输入框与可见视口底部的距离,只通过 `--platform-keyboard-focus-offset` 对平台根壳做 `translateY`。 +4. 输入法打开期间隐藏移动端底部 dock,避免 dock 被整体位移后遮住输入框。 +5. 该方案不新增 UI 说明文案,不改变业务组件结构,也不要求每个输入框单独适配。 + +## 验收 + +- 手机竖屏点击创作首页底部输入框时,页面内容不被压缩变矮。 +- 输入框随画面位移出现在输入法上方,可继续输入和发送。 +- 输入法关闭后,平台画布回到原位,底部 dock 恢复显示。 +- 未聚焦输入框时,平台首页仍保持原有移动端 `100dvh` / 固定 dock 行为。 diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 7bec1920..594d66ca 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -51,6 +51,7 @@ export type PuzzleAgentActionRequest = workDescription?: string; pictureDescription?: string; referenceImageSrc?: string | null; + referenceImageSrcs?: string[]; imageModel?: string | null; aiRedraw?: boolean; } @@ -61,6 +62,7 @@ export type PuzzleAgentActionRequest = workDescription?: string; pictureDescription?: string; referenceImageSrc?: string | null; + referenceImageSrcs?: string[]; imageModel?: string | null; aiRedraw?: boolean; candidateCount?: number; @@ -70,6 +72,7 @@ export type PuzzleAgentActionRequest = levelId?: string | null; promptText?: string | null; referenceImageSrc?: string | null; + referenceImageSrcs?: string[]; imageModel?: string | null; aiRedraw?: boolean; candidateCount?: number; diff --git a/packages/shared/src/contracts/puzzleAgentSession.ts b/packages/shared/src/contracts/puzzleAgentSession.ts index 779fd19d..f216f263 100644 --- a/packages/shared/src/contracts/puzzleAgentSession.ts +++ b/packages/shared/src/contracts/puzzleAgentSession.ts @@ -50,6 +50,7 @@ export interface CreatePuzzleAgentSessionRequest { workDescription?: string; pictureDescription?: string; referenceImageSrc?: string | null; + referenceImageSrcs?: string[]; imageModel?: string | null; aiRedraw?: boolean; } diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index 5c80d1a9..03448e27 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -155,11 +155,11 @@ function readApiErrorDetailMessage(details: unknown) { return ''; } - // 后端通用 message 常用于错误分类;details.message / details.reason - // 才是给用户定位问题的业务原因,配置缺失类错误通常只带 reason。 + // 后端通用 message 常用于错误分类;reason 更适合直接展示给用户, + // 例如 VectorEngine 网络分类会把底层 reqwest message 留给日志。 return ( - readTrimmedMessage(details.message) || - readTrimmedMessage(details.reason) + readTrimmedMessage(details.reason) || + readTrimmedMessage(details.message) ); } diff --git a/scripts/generate-bark-battle-assets.mjs b/scripts/generate-bark-battle-assets.mjs index 0fc94075..1a605519 100644 --- a/scripts/generate-bark-battle-assets.mjs +++ b/scripts/generate-bark-battle-assets.mjs @@ -35,7 +35,7 @@ function resolveEnv() { return { baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '').trim().replace(/\/+$/u, ''), apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(), - timeoutMs: Number.parseInt(String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || 180000), 10), + timeoutMs: Number.parseInt(String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || 1000000), 10), }; } diff --git a/scripts/generate-child-motion-demo-assets.mjs b/scripts/generate-child-motion-demo-assets.mjs index 33eccbd6..9f1a6690 100644 --- a/scripts/generate-child-motion-demo-assets.mjs +++ b/scripts/generate-child-motion-demo-assets.mjs @@ -13,7 +13,7 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); const assetDir = path.join(repoRoot, 'public', 'child-motion-demo'); const intermediateDir = path.join(repoRoot, 'tmp', 'child-motion-demo-assets'); -const defaultTimeoutMs = 180000; +const defaultTimeoutMs = 1000000; const chromaKeyColor = '#ff00ff'; const layoutReferenceOutput = 'picture-book-stage-layout-v2.png'; diff --git a/scripts/generate-match3d-style-references.mjs b/scripts/generate-match3d-style-references.mjs index f78e638f..653b5fd0 100644 --- a/scripts/generate-match3d-style-references.mjs +++ b/scripts/generate-match3d-style-references.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); const defaultOutDir = path.join(repoRoot, 'public', 'match3d-style-references'); -const defaultTimeoutMs = 180000; +const defaultTimeoutMs = 1000000; const styleTemplates = [ { diff --git a/scripts/generate-taonier-logo-concepts.mjs b/scripts/generate-taonier-logo-concepts.mjs index a68e13ee..cdc2899f 100644 --- a/scripts/generate-taonier-logo-concepts.mjs +++ b/scripts/generate-taonier-logo-concepts.mjs @@ -9,7 +9,7 @@ const outputDir = path.join( 'branding', 'taonier-logo-concepts', ); -const defaultTimeoutMs = 420000; +const defaultTimeoutMs = 1000000; const dimensionalConcepts = [ { diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index e55e2d65..b8af62a4 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -13,6 +13,7 @@ use platform_speech::{ const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge"; const DEFAULT_AUTH_STORE_PATH: &str = "server-rs/.data/auth-store.json"; const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json"; +pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000; // 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。 #[derive(Clone, Debug)] @@ -248,7 +249,7 @@ impl Default for AppConfig { apimart_image_request_timeout_ms: 180_000, vector_engine_base_url: String::new(), vector_engine_api_key: None, - vector_engine_image_request_timeout_ms: 180_000, + vector_engine_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, vector_engine_audio_request_timeout_ms: 180_000, hyper3d_base_url: "https://api.hyper3d.com/api/v2".to_string(), hyper3d_api_key: None, @@ -675,7 +676,9 @@ impl AppConfig { if let Some(vector_engine_image_request_timeout_ms) = read_first_positive_u64_env(&["VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"]) { - config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms; + // 中文注释:VectorEngine image-2 实测可能超过 500 秒;旧环境文件中常见的 180 秒值不能再提前截断真实生图。 + config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms + .max(DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS); } if let Some(vector_engine_audio_request_timeout_ms) = @@ -1009,7 +1012,7 @@ fn parse_positive_u16(raw: &str) -> Option { #[cfg(test)] mod tests { - use super::{AppConfig, LlmProvider}; + use super::{AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider}; use std::sync::{Mutex, OnceLock}; static ENV_LOCK: OnceLock> = OnceLock::new(); @@ -1094,7 +1097,10 @@ mod tests { config.vector_engine_base_url, "https://vector.internal.example" ); - assert_eq!(config.vector_engine_image_request_timeout_ms, 210_000); + assert_eq!( + config.vector_engine_image_request_timeout_ms, + DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS + ); assert_eq!( config.hyper3d_base_url, "https://model.internal.example/api/v2" diff --git a/server-rs/crates/api-server/src/edutainment_baby_object.rs b/server-rs/crates/api-server/src/edutainment_baby_object.rs index d3ae02e5..cbd0682f 100644 --- a/server-rs/crates/api-server/src/edutainment_baby_object.rs +++ b/server-rs/crates/api-server/src/edutainment_baby_object.rs @@ -15,6 +15,7 @@ use serde_json::{Value, json}; use crate::{ api_response::json_success_body, character_visual_assets::try_apply_background_alpha_to_png, + config::DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, http_error::AppError, openai_image_generation::{ DownloadedOpenAiImage, OpenAiImageSettings, build_openai_image_http_client, @@ -27,7 +28,6 @@ use crate::{ const BABY_OBJECT_MATCH_PROVIDER: &str = "vector-engine-gpt-image-2"; const BABY_OBJECT_MATCH_IMAGE_SIZE: &str = "1024x1024"; const BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE: &str = "1536x1024"; -const BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS: u64 = 480_000; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -207,7 +207,7 @@ fn build_baby_object_match_negative_prompt() -> &'static str { fn with_baby_object_match_image_timeout(mut settings: OpenAiImageSettings) -> OpenAiImageSettings { settings.request_timeout_ms = settings .request_timeout_ms - .max(BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS); + .max(DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS); settings } @@ -617,7 +617,7 @@ mod tests { assert_eq!( settings.request_timeout_ms, - BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS + DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS ); } diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index af00d89f..1bc4b72f 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -1023,32 +1023,14 @@ pub async fn generate_match3d_cover_image( .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; - upsert_match3d_draft_snapshot( + let item = update_match3d_work_cover_only( &state, &request_context, - &authenticated, - context.session_id.clone(), - context.owner_user_id.clone(), - profile_id.clone(), - Some(context.profile.game_name), - Some(context.profile.summary), - Some(serde_json::to_string(&context.profile.tags).unwrap_or_default()), - Some(generated_cover.src.clone()), - None, - None, + context.owner_user_id.as_str(), + context.profile, + generated_cover.src.as_str(), ) .await?; - let item = state - .spacetime_client() - .get_match3d_work_detail(profile_id.clone(), context.owner_user_id) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; Ok(json_success_body( Some(&request_context), @@ -1061,6 +1043,39 @@ pub async fn generate_match3d_cover_image( )) } +async fn update_match3d_work_cover_only( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + profile: Match3DWorkProfileRecord, + cover_image_src: &str, +) -> Result { + // 中文注释:封面生成是定向图片槽位更新,不能复用草稿编译路径重算题材、难度或素材 JSON。 + state + .spacetime_client() + .update_match3d_work(Match3DWorkUpdateRecordInput { + profile_id: profile.profile_id, + owner_user_id: owner_user_id.to_string(), + game_name: profile.game_name, + theme_text: profile.theme_text, + summary_text: profile.summary, + tags_json: serde_json::to_string(&normalize_tags(profile.tags)).unwrap_or_default(), + cover_image_src: cover_image_src.to_string(), + cover_asset_id: profile.cover_asset_id.unwrap_or_default(), + clear_count: profile.clear_count, + difficulty: profile.difficulty, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + }) +} + pub async fn generate_match3d_background_image_for_work( State(state): State, Path(profile_id): Path, @@ -4804,6 +4819,7 @@ async fn generate_match3d_background_image( "message": "抓大鹅容器 UI 图生成失败:未返回图片", })) })?; + let container_image = make_match3d_container_image_transparent(container_image)?; let container_upload = persist_match3d_generated_bytes( state, owner_user_id, @@ -4864,6 +4880,7 @@ async fn generate_match3d_container_image( "message": "抓大鹅容器 UI 图生成失败:未返回图片", })) })?; + let container_image = make_match3d_container_image_transparent(container_image)?; let container_upload = persist_match3d_generated_bytes( state, owner_user_id, @@ -4956,10 +4973,40 @@ fn build_match3d_container_generation_prompt(config: &Match3DConfigJson, prompt: .map(|style| format!("整体美术风格参考:{style}。")) .unwrap_or_default(); format!( - "{prompt}\n{style_clause}生成一张 1:1 抓大鹅中心容器 UI 图,只绘制一个贴合题材设定的圆形或浅盘状竞技容器。严格参考输入参考图的容器范围和视图角度:容器外轮廓必须接近画布四边,占画布宽度约 86%-92%、高度约 82%-90%,中心在画布中心略偏下,只保留少量透明或纯净留白;视角为轻俯视 3/4 上方视角,能看到圆形碗体外壁、厚实前沿和横向椭圆形内口,不能画成正俯视扁圆盘、侧视碗、小托盘或居中的小容器。容器需要有清晰外沿、内侧可放置 2D 物品的干净空间、轻微阴影和高辨识边界;背景必须透明感或纯净留白,不能做成整页背景。禁止文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层和菜单。" + "{prompt}\n{style_clause}生成一张 1:1 抓大鹅中心容器 UI 图,只绘制一个贴合题材设定的圆形或浅盘状竞技容器。严格参考输入参考图的容器范围和视图角度:容器外轮廓必须接近画布四边,占画布宽度约 86%-92%、高度约 82%-90%,中心在画布中心略偏下,只保留少量透明留白;视角为轻俯视 3/4 上方视角,能看到圆形碗体外壁、厚实前沿和横向椭圆形内口,不能画成正俯视扁圆盘、侧视碗、小托盘或居中的小容器。容器需要有清晰外沿、内侧可放置 2D 物品的干净空间、轻微阴影和高辨识边界;背景必须是透明 alpha,不得出现白底、纯色底、渐变底、场景底或整页背景。禁止文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层和菜单。" ) } +fn make_match3d_container_image_transparent( + image: DownloadedOpenAiImage, +) -> Result { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "match3d-assets", + "message": format!("抓大鹅容器图解码失败:{error}"), + })) + })?; + let mut rgba = source.to_rgba8(); + let (width, height) = rgba.dimensions(); + remove_match3d_container_plain_background(rgba.as_mut(), width as usize, height as usize); + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(rgba) + .write_to(&mut encoded, ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "match3d-assets", + "message": format!("抓大鹅容器图透明化失败:{error}"), + })) + })?; + + Ok(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + async fn generate_match3d_material_sheet( state: &AppState, config: &Match3DConfigJson, @@ -6232,6 +6279,45 @@ fn remove_match3d_material_green_screen_background( } } + // 中文注释:较厚的抗锯齿绿边可能低于 hard 阈值;先沿整张 sheet 的透明背景向内吃掉 + // 软绿边,再进入格子裁剪,避免每张切图自带绿色描边。 + let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14); + for _ in 0..soft_green_cleanup_rounds { + let mut expanded_mask = background_mask.clone(); + let mut changed_this_round = false; + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + let green_score = green_scores[pixel_index]; + let white_score = white_scores[pixel_index]; + if !is_match3d_material_soft_green_matte_pixel(pixel, green_score, white_score) { + continue; + } + if !touches_match3d_material_background_mask(x, y, width, height, &background_mask) + { + continue; + } + + expanded_mask[pixel_index] = 1; + changed_this_round = true; + } + } + background_mask = expanded_mask; + if !changed_this_round { + break; + } + } + // 中文注释:主体边缘常带一圈绿幕或白底抗锯齿,扩一层软边,避免切割后残留毛边。 for _ in 0..2 { let mut expanded_mask = background_mask.clone(); @@ -6372,9 +6458,10 @@ fn remove_match3d_material_green_screen_background( } } else { if green_score > 0.04 { - green = green - .max(red.max(blue)) - .max((green - (green - red.max(blue)) * 0.78).round()); + let toned_green = (green - (green - red.max(blue)) * 0.78) + .round() + .max(red.max(blue)); + green = green.min(toned_green).min(red.max(blue) + 18.0); } if white_score > 0.12 { @@ -6417,6 +6504,50 @@ fn remove_match3d_material_green_screen_background( changed } +fn touches_match3d_material_background_mask( + x: usize, + y: usize, + width: usize, + height: usize, + background_mask: &[u8], +) -> bool { + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { + return true; + } + if background_mask[next_y as usize * width + next_x as usize] != 0 { + return true; + } + } + } + false +} + +fn is_match3d_material_soft_green_matte_pixel( + pixel: [u8; 4], + green_score: f32, + white_score: f32, +) -> bool { + if pixel[3] == 0 || green_score < MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE { + return false; + } + + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + let foreground_mix = red.max(blue); + green >= 188 + && white_score < 0.34 + && green.saturating_sub(foreground_mix) >= 42 + && (red >= 48 || blue >= 96 || pixel[3] < 236) +} + fn compute_match3d_material_green_screen_score(pixel: [u8; 4]) -> f32 { if pixel[3] == 0 { return 1.0; @@ -6463,6 +6594,146 @@ fn compute_match3d_material_white_screen_score(pixel: [u8; 4]) -> f32 { clamp_match3d_material_unit(neutrality * (brightness * 0.85 + floor * 0.15)) } +fn remove_match3d_container_plain_background( + pixels: &mut [u8], + width: usize, + height: usize, +) -> bool { + let pixel_count = width.saturating_mul(height); + if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { + return false; + } + + let mut background_mask = vec![0u8; pixel_count]; + let mut queue = Vec::::new(); + let mut queue_index = 0usize; + + let seed_pixel = |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec| { + if background_mask[pixel_index] != 0 { + return; + } + let offset = pixel_index * 4; + if is_match3d_container_background_pixel([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]) { + background_mask[pixel_index] = 1; + queue.push(pixel_index); + } + }; + + for x in 0..width { + seed_pixel(x, &mut background_mask, &mut queue); + seed_pixel((height - 1) * width + x, &mut background_mask, &mut queue); + } + for y in 1..height.saturating_sub(1) { + seed_pixel(y * width, &mut background_mask, &mut queue); + seed_pixel(y * width + width - 1, &mut background_mask, &mut queue); + } + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + let x = pixel_index % width; + let y = pixel_index / width; + let neighbors = [ + (x > 0).then(|| pixel_index - 1), + (x + 1 < width).then_some(pixel_index + 1), + (y > 0).then(|| pixel_index - width), + (y + 1 < height).then_some(pixel_index + width), + ]; + + for next_pixel_index in neighbors.into_iter().flatten() { + if background_mask[next_pixel_index] != 0 { + continue; + } + let offset = next_pixel_index * 4; + if is_match3d_container_background_pixel([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]) { + background_mask[next_pixel_index] = 1; + queue.push(next_pixel_index); + } + } + } + + // 中文注释:图生图偶尔会在容器边缘留下白底抗锯齿,扩一层只清理连到背景的浅色边。 + for _ in 0..2 { + let mut expanded_mask = background_mask.clone(); + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_match3d_container_soft_background_pixel(pixel) { + continue; + } + + let mut adjacent_background_count = 0usize; + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 + || next_x >= width as i32 + || next_y < 0 + || next_y >= height as i32 + { + adjacent_background_count += 1; + continue; + } + if background_mask[next_y as usize * width + next_x as usize] != 0 { + adjacent_background_count += 1; + } + } + } + + if adjacent_background_count >= 3 { + expanded_mask[pixel_index] = 1; + } + } + } + background_mask = expanded_mask; + } + + let mut changed = false; + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 { + continue; + } + let offset = pixel_index * 4; + if pixels[offset + 3] != 0 { + pixels[offset + 3] = 0; + changed = true; + } + } + changed +} + +fn is_match3d_container_background_pixel(pixel: [u8; 4]) -> bool { + pixel[3] < 16 || compute_match3d_material_white_screen_score(pixel) > 0.34 +} + +fn is_match3d_container_soft_background_pixel(pixel: [u8; 4]) -> bool { + pixel[3] < 80 || compute_match3d_material_white_screen_score(pixel) > 0.18 +} + fn collect_match3d_material_foreground_neighbor_color( pixels: &[u8], width: usize, @@ -7148,6 +7419,51 @@ mod tests { ); } + #[test] + fn match3d_material_sheet_slicing_removes_soft_green_matte_before_crop() { + let width = 500; + let height = 500; + let item_names = vec!["草莓".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 28..72 { + for x in 28..72 { + sheet.put_pixel(x, y, image::Rgba([64, 198, 112, 255])); + } + } + for y in 36..64 { + for x in 36..64 { + sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + assert!( + decoded.pixels().all(|pixel| { + let [red, green, blue, alpha] = pixel.0; + alpha == 0 || green <= red.max(blue).saturating_add(32) + }), + "整张 sheet 去绿后再裁剪,输出 PNG 不能保留可见软绿边" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), + "软绿边清理不能误删物品主体" + ); + } + #[test] fn match3d_material_sheet_slicing_cleans_white_matte_edge() { let width = 500; @@ -7193,6 +7509,46 @@ mod tests { ); } + #[test] + fn match3d_container_image_postprocess_removes_plain_background() { + let width = 256; + let height = 256; + let mut image = + image::RgbaImage::from_pixel(width, height, image::Rgba([248, 248, 246, 255])); + for y in 68..190 { + for x in 38..218 { + image.put_pixel(x, y, image::Rgba([160, 104, 54, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, ImageFormat::Png) + .expect("container should encode"); + let processed = make_match3d_container_image_transparent(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) + .expect("container should postprocess"); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed container 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, + "容器图四周白底必须在入库前转成透明 alpha" + ); + assert_eq!( + decoded.get_pixel(width / 2, height / 2).0[3], + 255, + "容器主体不能被透明化误删" + ); + } + #[test] fn match3d_work_metadata_parses_gpt4o_json() { let metadata = parse_match3d_work_metadata( @@ -7544,12 +7900,12 @@ mod tests { let root_settings = Match3DVectorEngineGeminiImageSettings { base_url: "https://api.vectorengine.cn".to_string(), api_key: "test-key".to_string(), - request_timeout_ms: 180_000, + request_timeout_ms: 1_000_000, }; let v1_settings = Match3DVectorEngineGeminiImageSettings { base_url: "https://api.vectorengine.cn/v1".to_string(), api_key: "test-key".to_string(), - request_timeout_ms: 180_000, + request_timeout_ms: 1_000_000, }; assert_eq!( @@ -7584,7 +7940,9 @@ mod tests { assert!(container_prompt.contains("轻俯视 3/4")); assert!(container_prompt.contains("横向椭圆形内口")); assert!(container_prompt.contains("不能画成正俯视扁圆盘")); - assert!(container_prompt.contains("不能做成整页背景")); + assert!(container_prompt.contains("透明 alpha")); + assert!(container_prompt.contains("白底")); + assert!(container_prompt.contains("整页背景")); assert!(container_prompt.contains("禁止文字")); } diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index b7db6bb5..55554701 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -628,12 +628,12 @@ mod tests { let root_settings = OpenAiImageSettings { base_url: "https://vector.example".to_string(), api_key: "test-key".to_string(), - request_timeout_ms: 180_000, + request_timeout_ms: 1_000_000, }; let v1_settings = OpenAiImageSettings { base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), - request_timeout_ms: 180_000, + request_timeout_ms: 1_000_000, }; assert_eq!( diff --git a/server-rs/crates/api-server/src/prompt/puzzle/level_name.rs b/server-rs/crates/api-server/src/prompt/puzzle/level_name.rs index c6ca0d1e..d7930b44 100644 --- a/server-rs/crates/api-server/src/prompt/puzzle/level_name.rs +++ b/server-rs/crates/api-server/src/prompt/puzzle/level_name.rs @@ -1,34 +1,38 @@ -/// 拼图首关关卡名与 UI 背景提示词生成提示词。 +/// 拼图首关关卡名、作品元信息与 UI 背景提示词生成提示词。 /// -/// 模型只负责把画面描述压缩成可直接展示的中文关卡名,并产出运行态 UI 背景的正向视觉提示词; -/// 写回草稿和作品卡由业务路由处理。 +/// 模型只负责把画面描述压缩成可直接展示的中文关卡名、作品描述、作品标签, +/// 并产出运行态 UI 背景的正向视觉提示词;写回草稿和作品卡由业务路由处理。 pub(crate) const PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT: &str = r#"你是一个中文拼图关卡命名编辑。 你会收到拼图第一关的画面描述,部分请求还会附带已经生成完成的正式图片。请综合图片内容和画面描述,同时生成: - 1 个适合直接展示在游戏关卡卡片上的中文关卡名。 +- 1 段适合默认填入拼图草稿的中文作品描述。 +- 6 个适合作品广场检索和相似推荐的中文作品标签。 - 1 段用于生成 9:16 拼图运行态 UI 纯背景图的中文正向视觉提示词。 硬约束: 1. 只输出 JSON,不要输出 Markdown、解释或代码块。 -2. JSON 格式必须是 {"levelName":"关卡名","uiBackgroundPrompt":"提示词"}。 +2. JSON 格式必须是 {"levelName":"关卡名","workDescription":"作品描述","workTags":["标签1","标签2","标签3","标签4","标签5","标签6"],"uiBackgroundPrompt":"提示词"}。 3. levelName 必须是 2 到 8 个中文字符为主。 4. 不要输出“第一关”“画面”“拼图”“作品”等泛词。 5. 不要输出标点、引号、编号、英文、emoji 或空白。 6. 关卡名要抓住画面主体、场景和氛围,读起来像一个具体可玩的关卡。 -7. uiBackgroundPrompt 必须是 30 到 160 个中文字符,描述题材氛围、环境、色彩、光影和空间层次。 -8. uiBackgroundPrompt 只写正向画面描述,不要写规则说明,不要出现拼图槽、棋盘、HUD、按钮、文字、水印、数字、拼图碎片、完整拼图图像或教程浮层。 +7. workDescription 必须是 18 到 80 个中文字符,描述这套拼图的画面主题、氛围和游玩期待,不要复述字段名。 +8. workTags 必须正好 6 个,每个标签 2 到 6 个中文字符为主,覆盖题材、主体、氛围、场景、风格和拼图辨识点。 +9. uiBackgroundPrompt 必须是 30 到 160 个中文字符,描述题材氛围、环境、色彩、光影和空间层次。 +10. uiBackgroundPrompt 只写正向画面描述,不要写规则说明,不要出现拼图槽、棋盘、HUD、按钮、文字、水印、数字、拼图碎片、完整拼图图像或教程浮层。 "#; pub(crate) fn build_puzzle_first_level_name_user_prompt(picture_description: &str) -> String { format!( - "画面描述:{picture_description}\n\n请生成第一关关卡名和 UI 背景提示词。", + "画面描述:{picture_description}\n\n请生成第一关关卡名、作品描述、6 个作品标签和 UI 背景提示词。", picture_description = picture_description.trim(), ) } pub(crate) fn build_puzzle_first_level_name_vision_user_text(picture_description: &str) -> String { format!( - "画面描述:{picture_description}\n\n请观察随消息附带的正式拼图图片,生成第一关关卡名和 UI 背景提示词。", + "画面描述:{picture_description}\n\n请观察随消息附带的正式拼图图片,生成第一关关卡名、作品描述、6 个作品标签和 UI 背景提示词。", picture_description = picture_description.trim(), ) } @@ -43,6 +47,8 @@ mod tests { assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。")); assert!(prompt.contains("第一关关卡名")); + assert!(prompt.contains("作品描述")); + assert!(prompt.contains("6 个作品标签")); assert!(prompt.contains("UI 背景提示词")); } @@ -52,6 +58,8 @@ mod tests { assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。")); assert!(prompt.contains("正式拼图图片")); + assert!(prompt.contains("作品描述")); + assert!(prompt.contains("6 个作品标签")); assert!(prompt.contains("UI 背景提示词")); } } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 3f52e88d..3e2503ea 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -122,7 +122,9 @@ const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024"; const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024"; const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768; +const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512; const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024; +const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5; const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2"; const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str = "移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素"; @@ -718,16 +720,20 @@ pub async fn execute_puzzle_agent_action( .as_deref() .map(|value| value.chars().count()) .unwrap_or(0), - has_reference_image = payload - .reference_image_src - .as_deref() - .map(|value| !value.trim().is_empty()) - .unwrap_or(false), + has_reference_image = has_puzzle_reference_images( + payload.reference_image_src.as_deref(), + payload.reference_image_srcs.as_slice(), + ), "拼图 Agent action 开始执行" ); let (operation_type, phase_label, phase_detail, session) = match action.as_str() { "compile_puzzle_draft" => { let ai_redraw = payload.ai_redraw.unwrap_or(true); + let reference_image_sources = collect_puzzle_reference_image_sources( + payload.reference_image_src.as_deref(), + payload.reference_image_srcs.as_slice(), + ); + let primary_reference_image_src = reference_image_sources.first().map(String::as_str); let prompt_text = payload .picture_description .as_deref() @@ -760,7 +766,7 @@ pub async fn execute_puzzle_agent_action( compile_session_id.clone(), owner_user_id.clone(), prompt_text, - payload.reference_image_src.as_deref(), + primary_reference_image_src, payload.image_model.as_deref(), now, ) @@ -891,6 +897,12 @@ pub async fn execute_puzzle_agent_action( payload.prompt_text.as_deref(), &target_level.picture_description, ); + let reference_image_sources = collect_puzzle_reference_image_sources( + payload.reference_image_src.as_deref(), + payload.reference_image_srcs.as_slice(), + ); + let primary_reference_image_src = + reference_image_sources.first().map(String::as_str); // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 let candidate_count = 1; let candidate_start_index = target_level.candidates.len(); @@ -900,7 +912,7 @@ pub async fn execute_puzzle_agent_action( &session.session_id, &target_level.level_name, &prompt, - payload.reference_image_src.as_deref(), + primary_reference_image_src, payload.ai_redraw.unwrap_or(true), payload.image_model.as_deref(), candidate_count, @@ -934,7 +946,7 @@ pub async fn execute_puzzle_agent_action( &build_puzzle_levels_with_primary_update( &draft, &target_level, - payload.reference_image_src.as_deref(), + primary_reference_image_src, ), )?); let candidates_json = serde_json::to_string( @@ -985,7 +997,7 @@ pub async fn execute_puzzle_agent_action( ), target_level.level_id.as_str(), candidates.into_records(), - payload.reference_image_src.as_deref(), + primary_reference_image_src, now, )) } @@ -3067,6 +3079,8 @@ fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) { #[derive(Clone, Debug, Eq, PartialEq)] struct PuzzleLevelNaming { level_name: String, + work_description: Option, + work_tags: Vec, ui_background_prompt: Option, } @@ -3074,6 +3088,8 @@ impl PuzzleLevelNaming { fn fallback(picture_description: &str) -> Self { Self { level_name: build_fallback_puzzle_first_level_name(picture_description), + work_description: None, + work_tags: Vec::new(), ui_background_prompt: None, } } @@ -3150,7 +3166,7 @@ async fn generate_puzzle_first_level_name_from_image( ]), ]) .with_model(PUZZLE_LEVEL_NAME_VISION_LLM_MODEL) - .with_max_tokens(80), + .with_max_tokens(PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS), ) .await; @@ -3217,6 +3233,9 @@ fn parse_puzzle_level_naming_from_text(text: &str) -> Option trimmed }; let parsed = serde_json::from_str::(json_text).ok(); + if parsed.is_none() && looks_like_puzzle_json_fragment(trimmed) { + return None; + } let raw_name = parsed .as_ref() .and_then(|value| value.get("levelName").and_then(Value::as_str)) @@ -3227,12 +3246,21 @@ fn parse_puzzle_level_naming_from_text(text: &str) -> Option }) .unwrap_or(trimmed); let level_name = normalize_puzzle_first_level_name(raw_name)?; + let work_description = parsed + .as_ref() + .and_then(parse_puzzle_generated_work_description_field); + let work_tags = parsed + .as_ref() + .and_then(parse_puzzle_generated_work_tags_field) + .unwrap_or_default(); let ui_background_prompt = parsed .as_ref() .and_then(parse_puzzle_ui_background_prompt_field); Some(PuzzleLevelNaming { level_name, + work_description, + work_tags, ui_background_prompt, }) } @@ -3250,6 +3278,55 @@ fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option { .and_then(normalize_puzzle_generated_ui_background_prompt) } +fn parse_puzzle_generated_work_description_field(value: &Value) -> Option { + value + .get("workDescription") + .and_then(Value::as_str) + .or_else(|| value.get("work_description").and_then(Value::as_str)) + .and_then(normalize_puzzle_generated_work_description) +} + +fn normalize_puzzle_generated_work_description(value: &str) -> Option { + let normalized = value + .trim() + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .split_whitespace() + .collect::>() + .join(""); + let description = normalized.chars().take(80).collect::(); + (description.chars().count() >= 8 && !looks_like_puzzle_json_field_name(&description)) + .then_some(description) +} + +fn parse_puzzle_generated_work_tags_field(value: &Value) -> Option> { + let tags_value = value + .get("workTags") + .or_else(|| value.get("work_tags")) + .or_else(|| value.get("themeTags")) + .or_else(|| value.get("theme_tags")) + .or_else(|| value.get("tags"))?; + let raw_tags = match tags_value { + Value::Array(items) => items + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect::>(), + Value::String(text) => text + .split([',', ',', '、', '\n', '|', '/']) + .map(ToString::to_string) + .collect::>(), + _ => Vec::new(), + }; + let tags = normalize_puzzle_generated_work_tag_candidates(raw_tags); + (tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT).then_some(tags) +} + fn normalize_puzzle_generated_ui_background_prompt(value: &str) -> Option { let normalized = value .trim() @@ -3331,6 +3408,7 @@ fn normalize_puzzle_first_level_name(value: &str) -> Option { normalized.as_str(), "第一关" | "画面" | "拼图" | "作品" | "关卡" ) + && !looks_like_puzzle_json_field_name(&normalized) { Some(normalized) } else { @@ -3338,6 +3416,52 @@ fn normalize_puzzle_first_level_name(value: &str) -> Option { } } +fn looks_like_puzzle_json_field_name(value: &str) -> bool { + let normalized = value.trim().trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }); + let compact = normalized.to_ascii_lowercase().replace('_', ""); + matches!(compact.as_str(), "levelnam" | "levelname") + || [ + "levelname", + "workdescription", + "worktags", + "themetags", + "uibackgroundprompt", + ] + .iter() + .any(|field| { + compact == *field + || (compact.len() >= 6 && field.starts_with(compact.as_str())) + || compact.starts_with(field) + }) +} + +fn looks_like_puzzle_json_fragment(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.starts_with('{') || trimmed.starts_with('[') { + return true; + } + let lower = trimmed.to_ascii_lowercase(); + [ + "\"levelnam", + "\"levelname\"", + "\"level_name\"", + "\"workdescription\"", + "\"work_description\"", + "\"worktags\"", + "\"work_tags\"", + "\"uibackgroundprompt\"", + "\"ui_background_prompt\"", + ] + .iter() + .any(|field| lower.contains(field)) +} + fn strip_puzzle_level_name_generic_words(mut value: String) -> String { for prefix in ["第一关", "关卡名", "关卡"] { value = value.trim_start_matches(prefix).to_string(); @@ -3406,6 +3530,28 @@ fn build_puzzle_levels_with_primary_update( levels } +fn attach_selected_puzzle_candidate_to_levels( + levels: &mut [PuzzleDraftLevelRecord], + target_level_id: &str, + candidate: &PuzzleGeneratedImageCandidateRecord, +) { + if let Some(index) = levels + .iter() + .position(|level| level.level_id == target_level_id) + .or_else(|| (!levels.is_empty()).then_some(0)) + { + let level = &mut levels[index]; + level.candidates.clear(); + let mut candidate = candidate.clone(); + candidate.selected = true; + level.selected_candidate_id = Some(candidate.candidate_id.clone()); + level.cover_image_src = Some(candidate.image_src.clone()); + level.cover_asset_id = Some(candidate.asset_id.clone()); + level.candidates.push(candidate); + level.generation_status = "ready".to_string(); + } +} + fn resolve_puzzle_initial_ui_background_prompt( draft: &PuzzleResultDraftRecord, target_level: &PuzzleDraftLevelRecord, @@ -3596,7 +3742,8 @@ async fn compile_puzzle_draft_with_initial_cover( ); let (generated_naming, candidates_result) = tokio::join!(level_name_future, candidates_future); target_level.level_name = generated_naming.level_name.clone(); - target_level.ui_background_prompt = generated_naming.ui_background_prompt; + target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); + let mut generated_metadata = generated_naming; let candidates = candidates_result?; let selected_candidate_id = candidates .iter() @@ -3620,6 +3767,14 @@ async fn compile_puzzle_draft_with_initial_cover( if refined_naming.ui_background_prompt.is_some() { target_level.ui_background_prompt = refined_naming.ui_background_prompt; } + if refined_naming.work_description.is_some() { + generated_metadata.work_description = refined_naming.work_description; + } + if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { + generated_metadata.work_tags = refined_naming.work_tags; + } + generated_metadata.level_name = target_level.level_name.clone(); + generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone(); } let generated_level_name = target_level.level_name.clone(); let mut updated_levels = @@ -3639,6 +3794,17 @@ async fn compile_puzzle_draft_with_initial_cover( ui_prompt, ui_background, ); + if let Some(selected_candidate) = candidates + .iter() + .find(|candidate| candidate.record.selected) + .or_else(|| candidates.first()) + { + attach_selected_puzzle_candidate_to_levels( + &mut updated_levels, + target_level.level_id.as_str(), + &selected_candidate.record, + ); + } let ready_level = find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str()) .ok_or_else(|| { @@ -3650,6 +3816,28 @@ async fn compile_puzzle_draft_with_initial_cover( ensure_puzzle_initial_level_assets_ready(ready_level)?; let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(&updated_levels)?); + let work_title = if draft.work_title.trim().is_empty() + || draft.work_title.trim() == fallback_level_name.trim() + { + generated_level_name.clone() + } else { + draft.work_title.clone() + }; + let work_description = if draft.work_description.trim().is_empty() { + generated_metadata + .work_description + .clone() + .unwrap_or_else(|| draft.work_description.clone()) + } else { + draft.work_description.clone() + }; + let theme_tags = if draft.theme_tags.is_empty() + && generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT + { + generated_metadata.work_tags.clone() + } else { + draft.theme_tags.clone() + }; let candidates_json = serde_json::to_string( &candidates .iter() @@ -3707,6 +3895,43 @@ async fn compile_puzzle_draft_with_initial_cover( Err(error) } })?; + let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); + match state + .spacetime_client() + .update_puzzle_work(PuzzleWorkUpsertRecordInput { + profile_id, + owner_user_id: owner_user_id.clone(), + work_title, + work_description: work_description.clone(), + level_name: generated_level_name.clone(), + summary: work_description, + theme_tags, + cover_image_src: ready_level.cover_image_src.clone(), + cover_asset_id: ready_level.cover_asset_id.clone(), + levels_json: levels_json_with_generated_name.clone(), + updated_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error) + { + Ok(_) => {} + Err(error) if is_spacetimedb_connectivity_app_error(&error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compiled_session.session_id, + owner_user_id = %owner_user_id, + message = %error.body_text(), + "拼图首图生成后作品元信息投影回写不可用,继续使用会话草稿快照" + ); + } + Err(error) => return Err(error), + } + let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot( + saved_session, + &generated_metadata, + fallback_level_name.as_str(), + now, + ); if save_used_fallback { return Ok(saved_session); } @@ -3721,7 +3946,12 @@ async fn compile_puzzle_draft_with_initial_cover( }) .await { - Ok(session) => Ok(session), + Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot( + session, + &generated_metadata, + fallback_level_name.as_str(), + now, + )), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, @@ -3821,9 +4051,18 @@ async fn compile_puzzle_draft_with_uploaded_cover( if refined_naming.ui_background_prompt.is_some() { generated_naming.ui_background_prompt = refined_naming.ui_background_prompt; } + if refined_naming.work_description.is_some() { + generated_naming.work_description = refined_naming.work_description; + } + if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { + generated_naming.work_tags = refined_naming.work_tags; + } } - target_level.level_name = generated_naming.level_name; - target_level.ui_background_prompt = generated_naming.ui_background_prompt; + target_level.level_name = generated_naming.level_name.clone(); + target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); + let mut generated_metadata = generated_naming; + generated_metadata.level_name = target_level.level_name.clone(); + generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone(); let generated_level_name = target_level.level_name.clone(); let persisted_upload = persisted_upload_result?; let mut updated_levels = @@ -3843,6 +4082,19 @@ async fn compile_puzzle_draft_with_uploaded_cover( ui_prompt, ui_background, ); + attach_selected_puzzle_candidate_to_levels( + &mut updated_levels, + target_level.level_id.as_str(), + &PuzzleGeneratedImageCandidateRecord { + candidate_id: candidate_id.clone(), + image_src: persisted_upload.image_src.clone(), + asset_id: persisted_upload.asset_id.clone(), + prompt: image_prompt.clone(), + actual_prompt: None, + source_type: "uploaded".to_string(), + selected: true, + }, + ); let ready_level = find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str()) .ok_or_else(|| { @@ -3854,6 +4106,28 @@ async fn compile_puzzle_draft_with_uploaded_cover( ensure_puzzle_initial_level_assets_ready(ready_level)?; let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(&updated_levels)?); + let work_title = if draft.work_title.trim().is_empty() + || draft.work_title.trim() == fallback_level_name.trim() + { + generated_level_name.clone() + } else { + draft.work_title.clone() + }; + let work_description = if draft.work_description.trim().is_empty() { + generated_metadata + .work_description + .clone() + .unwrap_or_else(|| draft.work_description.clone()) + } else { + draft.work_description.clone() + }; + let theme_tags = if draft.theme_tags.is_empty() + && generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT + { + generated_metadata.work_tags.clone() + } else { + draft.theme_tags.clone() + }; let candidate = PuzzleGeneratedImageCandidateRecord { candidate_id: candidate_id.clone(), image_src: persisted_upload.image_src, @@ -3916,6 +4190,43 @@ async fn compile_puzzle_draft_with_uploaded_cover( Err(error) } })?; + let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); + match state + .spacetime_client() + .update_puzzle_work(PuzzleWorkUpsertRecordInput { + profile_id, + owner_user_id: owner_user_id.clone(), + work_title, + work_description: work_description.clone(), + level_name: generated_level_name.clone(), + summary: work_description, + theme_tags, + cover_image_src: ready_level.cover_image_src.clone(), + cover_asset_id: ready_level.cover_asset_id.clone(), + levels_json: levels_json_with_generated_name.clone(), + updated_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error) + { + Ok(_) => {} + Err(error) if is_spacetimedb_connectivity_app_error(&error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compiled_session.session_id, + owner_user_id = %owner_user_id, + message = %error.body_text(), + "拼图上传图草稿作品元信息投影回写不可用,继续使用会话草稿快照" + ); + } + Err(error) => return Err(error), + } + let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot( + saved_session, + &generated_metadata, + fallback_level_name.as_str(), + now, + ); if save_used_fallback { return Ok(saved_session); } @@ -3930,7 +4241,12 @@ async fn compile_puzzle_draft_with_uploaded_cover( }) .await { - Ok(session) => Ok(session), + Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot( + session, + &generated_metadata, + fallback_level_name.as_str(), + now, + )), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, @@ -4046,6 +4362,53 @@ fn apply_generated_puzzle_first_level_name_to_session_snapshot( session } +fn apply_generated_puzzle_initial_metadata_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + metadata: &PuzzleLevelNaming, + previous_level_name: &str, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + apply_generated_puzzle_initial_metadata_to_draft( + draft, + metadata, + previous_level_name, + updated_at_micros, + ); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +fn apply_generated_puzzle_initial_metadata_to_draft( + draft: &mut PuzzleResultDraftRecord, + metadata: &PuzzleLevelNaming, + previous_level_name: &str, + _updated_at_micros: i64, +) { + let should_default_work_title = + draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim(); + if should_default_work_title { + draft.work_title = metadata.level_name.clone(); + } + + if draft.work_description.trim().is_empty() + && let Some(description) = metadata.work_description.as_ref() + { + draft.work_description = description.clone(); + draft.summary = description.clone(); + } + + if draft.theme_tags.is_empty() + && metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT + { + draft.theme_tags = metadata.work_tags.clone(); + } + + sync_puzzle_primary_draft_fields_from_level(draft); +} + fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) { let Some(primary_level) = draft.levels.first() else { return; @@ -4056,6 +4419,16 @@ fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftReco draft.cover_image_src = primary_level.cover_image_src.clone(); draft.cover_asset_id = primary_level.cover_asset_id.clone(); draft.generation_status = primary_level.generation_status.clone(); + draft.summary = draft.work_description.clone(); + if draft.form_draft.is_some() { + draft.form_draft = Some(PuzzleFormDraftRecord { + work_title: (!draft.work_title.trim().is_empty()).then_some(draft.work_title.clone()), + work_description: (!draft.work_description.trim().is_empty()) + .then_some(draft.work_description.clone()), + picture_description: (!primary_level.picture_description.trim().is_empty()) + .then_some(primary_level.picture_description.clone()), + }); + } } fn replace_puzzle_session_draft_snapshot( @@ -4170,6 +4543,9 @@ where { let mut tags = Vec::new(); for candidate in candidates { + if looks_like_puzzle_json_field_name(candidate.as_ref()) { + continue; + } let normalized = normalize_puzzle_tag(candidate.as_ref()); if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) { continue; @@ -4190,6 +4566,29 @@ where tags } +fn normalize_puzzle_generated_work_tag_candidates( + candidates: impl IntoIterator, +) -> Vec +where + S: AsRef, +{ + let mut tags = Vec::new(); + for candidate in candidates { + let normalized = normalize_puzzle_tag(candidate.as_ref()); + if normalized.is_empty() + || looks_like_puzzle_json_field_name(&normalized) + || tags.iter().any(|tag| tag == &normalized) + { + continue; + } + tags.push(normalized); + if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT { + break; + } + } + tags +} + fn normalize_puzzle_tag(value: &str) -> String { value .trim() @@ -4931,6 +5330,26 @@ mod tests { )); } + #[test] + fn puzzle_reference_image_sources_are_deduped_and_limited() { + let sources = collect_puzzle_reference_image_sources( + Some("data:image/png;base64,a"), + &[ + "data:image/png;base64,a".to_string(), + "data:image/png;base64,b".to_string(), + "data:image/png;base64,c".to_string(), + "data:image/png;base64,d".to_string(), + "data:image/png;base64,e".to_string(), + "data:image/png;base64,f".to_string(), + ], + ); + + assert_eq!(sources.len(), 5); + assert_eq!(sources[0], "data:image/png;base64,a"); + assert_eq!(sources[1], "data:image/png;base64,b"); + assert!(!sources.contains(&"data:image/png;base64,f".to_string())); + } + #[test] fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() { let error = map_puzzle_vector_engine_request_error( @@ -5008,6 +5427,7 @@ mod tests { action: "generate_puzzle_images".to_string(), prompt_text: None, reference_image_src: None, + reference_image_srcs: Vec::new(), image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), ai_redraw: None, candidate_count: Some(1), @@ -5055,16 +5475,28 @@ mod tests { parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#), Some("雨夜猫街".to_string()) ); + assert_eq!( + parse_puzzle_first_level_name_from_text(r#"{"levelNam"#), + None + ); } #[test] - fn puzzle_level_naming_parser_accepts_ui_background_prompt() { + fn puzzle_level_naming_parser_accepts_metadata_and_ui_background_prompt() { let naming = parse_puzzle_level_naming_from_text( - r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#, + r#"{"levelName":"雨夜猫街","workDescription":"在湿润灯牌与猫影之间完成一套雨夜街角拼图。","workTags":["雨夜","猫咪","灯牌","街角","暖色","插画"],"uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#, ) .expect("naming should parse"); assert_eq!(naming.level_name, "雨夜猫街"); + assert_eq!( + naming.work_description.as_deref(), + Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图") + ); + assert_eq!(naming.work_tags.len(), module_puzzle::PUZZLE_MAX_TAG_COUNT); + assert!(naming.work_tags.contains(&"雨夜".to_string())); + assert!(naming.work_tags.contains(&"猫咪".to_string())); + assert!(naming.work_tags.contains(&"灯牌".to_string())); assert_eq!( naming.ui_background_prompt.as_deref(), Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次") @@ -5139,6 +5571,7 @@ mod tests { action: "generate_puzzle_images".to_string(), prompt_text: None, reference_image_src: None, + reference_image_srcs: Vec::new(), image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), ai_redraw: None, candidate_count: Some(1), @@ -5173,6 +5606,61 @@ mod tests { assert_eq!(draft.levels[0].level_name, "雨夜猫街"); } + #[test] + fn puzzle_initial_metadata_defaults_empty_work_description_and_tags() { + let mut session = PuzzleAgentSessionRecord { + session_id: "puzzle-session-1".to_string(), + seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(), + current_turn: 1, + progress_percent: 94, + stage: "ready_to_publish".to_string(), + anchor_pack: test_puzzle_anchor_pack_record(), + draft: Some(test_puzzle_draft_record()), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + suggested_actions: Vec::new(), + result_preview: None, + updated_at: "2024-01-01T00:00:00Z".to_string(), + }; + { + let draft = session.draft.as_mut().expect("draft"); + draft.work_title = "猫画面".to_string(); + draft.work_description = String::new(); + draft.summary = String::new(); + draft.theme_tags = Vec::new(); + } + let metadata = PuzzleLevelNaming { + level_name: "雨夜猫街".to_string(), + work_description: Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图".to_string()), + work_tags: vec![ + "插画".to_string(), + "灯牌".to_string(), + "街角".to_string(), + "猫咪".to_string(), + "暖色".to_string(), + "雨夜".to_string(), + ], + ui_background_prompt: None, + }; + + let session = apply_generated_puzzle_initial_metadata_to_session_snapshot( + session, + &metadata, + "猫画面", + 1_713_686_401_234_568, + ); + + let draft = session.draft.expect("draft"); + assert_eq!(draft.work_title, "雨夜猫街"); + assert_eq!( + draft.work_description, + "在湿润灯牌与猫影之间完成一套雨夜街角拼图" + ); + assert_eq!(draft.summary, draft.work_description); + assert_eq!(draft.theme_tags, metadata.work_tags); + } + #[test] fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() { let level = PuzzleDraftLevelResponse { @@ -5981,6 +6469,40 @@ fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool { .unwrap_or(false) } +fn collect_puzzle_reference_image_sources( + legacy_reference_image_src: Option<&str>, + reference_image_srcs: &[String], +) -> Vec { + let mut sources = Vec::new(); + for source in legacy_reference_image_src + .into_iter() + .chain(reference_image_srcs.iter().map(String::as_str)) + { + let normalized = source.trim(); + if normalized.is_empty() { + continue; + } + if !sources + .iter() + .any(|existing: &String| existing == normalized) + { + sources.push(normalized.to_string()); + } + if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT { + break; + } + } + sources +} + +fn has_puzzle_reference_images( + legacy_reference_image_src: Option<&str>, + reference_image_srcs: &[String], +) -> bool { + !collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs) + .is_empty() +} + fn should_use_puzzle_reference_image_edit( reference_image_src: Option<&str>, use_reference_image_edit: bool, diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 66d1d47c..acd18718 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -476,6 +476,10 @@ mod tests { RuntimeProfileWalletLedgerSourceType::SnapshotSync.as_str(), "snapshot_sync" ); + assert_eq!( + RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward.as_str(), + "new_user_registration_reward" + ); assert_eq!( RuntimeProfileWalletLedgerSourceType::PointsRecharge.as_str(), "points_recharge" @@ -494,6 +498,19 @@ mod tests { ); } + #[test] + fn new_user_registration_wallet_reward_starts_with_ten_points() { + assert_eq!(PROFILE_NEW_USER_INITIAL_WALLET_POINTS, 10); + assert_eq!( + calculate_runtime_profile_wallet_balance( + 0, + PROFILE_NEW_USER_INITIAL_WALLET_POINTS as i64, + ) + .expect("new user registration reward should fit wallet balance"), + 10 + ); + } + #[test] fn runtime_profile_beijing_day_key_uses_business_day_boundary() { let before_beijing_midnight = 1_714_927_999_999_999; diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index 0276dccb..32f79da4 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -16,6 +16,8 @@ pub struct CreatePuzzleAgentSessionRequest { #[serde(default)] pub reference_image_src: Option, #[serde(default)] + pub reference_image_srcs: Vec, + #[serde(default)] pub image_model: Option, #[serde(default)] pub ai_redraw: Option, @@ -39,6 +41,8 @@ pub struct ExecutePuzzleAgentActionRequest { #[serde(default)] pub reference_image_src: Option, #[serde(default)] + pub reference_image_srcs: Vec, + #[serde(default)] pub image_model: Option, #[serde(default)] pub ai_redraw: Option, diff --git a/server-rs/crates/spacetime-module/src/match3d/mod.rs b/server-rs/crates/spacetime-module/src/match3d/mod.rs index 754d7f2d..e37c3dd1 100644 --- a/server-rs/crates/spacetime-module/src/match3d/mod.rs +++ b/server-rs/crates/spacetime-module/src/match3d/mod.rs @@ -544,6 +544,16 @@ fn update_match3d_work_tx( input: Match3DWorkUpdateInput, ) -> Result { let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + let next = build_updated_match3d_work_row(¤t, &input)?; + let snapshot = build_work_snapshot(&next)?; + replace_work(ctx, ¤t, next); + Ok(snapshot) +} + +fn build_updated_match3d_work_row( + current: &Match3DWorkProfileRow, + input: &Match3DWorkUpdateInput, +) -> Result { let tags = parse_tags(&input.tags_json)?; let config = Match3DCreatorConfigSnapshot { theme_text: clean_string(&input.theme_text, "经典消除"), @@ -563,7 +573,7 @@ fn update_match3d_work_tx( author_display_name: current.author_display_name.clone(), game_name: clean_string(&input.game_name, "未命名抓大鹅"), theme_text: config.theme_text.clone(), - summary_text: clean_string(&input.summary_text, "经典消除玩法"), + summary_text: input.summary_text.trim().to_string(), tags_json: to_json_string(&tags), cover_image_src: input.cover_image_src.trim().to_string(), cover_asset_id: input.cover_asset_id.trim().to_string(), @@ -576,9 +586,7 @@ fn update_match3d_work_tx( published_at: current.published_at, generated_item_assets_json: current.generated_item_assets_json.clone(), }; - let snapshot = build_work_snapshot(&next)?; - replace_work(ctx, ¤t, next); - Ok(snapshot) + Ok(next) } fn publish_match3d_work_tx( @@ -1881,6 +1889,65 @@ mod tests { ); } + #[test] + fn match3d_work_update_preserves_assets_and_allows_empty_summary() { + let existing = Match3DWorkProfileRow { + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: "session-1".to_string(), + author_display_name: "作者".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary_text: "保留描述".to_string(), + tags_json: "[\"水果\"]".to_string(), + cover_image_src: "/old-cover.png".to_string(), + cover_asset_id: "cover-asset-1".to_string(), + clear_count: 12, + difficulty: 4, + config_json: to_json_string(&Match3DCreatorConfigSnapshot { + theme_text: "水果".to_string(), + reference_image_src: None, + clear_count: 12, + difficulty: 4, + asset_style_id: None, + asset_style_label: None, + asset_style_prompt: None, + generate_click_sound: false, + }), + publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(), + play_count: 2, + updated_at: Timestamp::from_micros_since_unix_epoch(1), + published_at: None, + generated_item_assets_json: Some( + r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"# + .to_string(), + ), + }; + let input = Match3DWorkUpdateInput { + profile_id: existing.profile_id.clone(), + owner_user_id: existing.owner_user_id.clone(), + game_name: existing.game_name.clone(), + theme_text: existing.theme_text.clone(), + summary_text: " ".to_string(), + tags_json: existing.tags_json.clone(), + cover_image_src: "/new-cover.png".to_string(), + cover_asset_id: existing.cover_asset_id.clone(), + clear_count: existing.clear_count, + difficulty: existing.difficulty, + updated_at_micros: 2, + }; + let next = build_updated_match3d_work_row(&existing, &input).unwrap(); + + assert_eq!(next.summary_text, ""); + assert_eq!(next.cover_image_src, "/new-cover.png"); + assert_eq!(next.clear_count, 12); + assert_eq!(next.difficulty, 4); + assert_eq!( + next.generated_item_assets_json.as_deref(), + existing.generated_item_assets_json.as_deref() + ); + } + #[test] fn match3d_publish_ready_requires_five_image_views_per_item() { let base_work = Match3DWorkProfileRow { diff --git a/spacetime.local.json b/spacetime.local.json index 60c2b4c2..77ed651c 100644 --- a/spacetime.local.json +++ b/spacetime.local.json @@ -1,3 +1,3 @@ { - "database": "genarrative-dev-edu" + "database": "xushi-p4wfr" } diff --git a/src/components/CustomWorldGenerationView.test.tsx b/src/components/CustomWorldGenerationView.test.tsx new file mode 100644 index 00000000..0e589b13 --- /dev/null +++ b/src/components/CustomWorldGenerationView.test.tsx @@ -0,0 +1,129 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime'; +import { CustomWorldGenerationView } from './CustomWorldGenerationView'; + +function createProgress( + overrides: Partial = {}, +): CustomWorldGenerationProgress { + return { + phaseId: 'draft_foundation', + phaseLabel: '整理草稿', + phaseDetail: '正在整理当前生成步骤。', + batchLabel: '第 2 批', + overallProgress: 42, + completedWeight: 21, + totalWeight: 50, + elapsedMs: 125_000, + estimatedRemainingMs: 75_000, + activeStepIndex: 1, + steps: [ + { + id: 'step-1', + label: '收集设定', + detail: '整理初始输入。', + completed: 1, + total: 1, + status: 'completed', + }, + { + id: 'step-2', + label: '编译草稿', + detail: '生成首版结构。', + completed: 2, + total: 4, + status: 'active', + }, + { + id: 'step-3', + label: '写回结果', + detail: '同步结果页。', + completed: 0, + total: 4, + status: 'pending', + }, + ], + ...overrides, + }; +} + +describe('CustomWorldGenerationView', () => { + test.each(['拼图草稿生成进度', '抓大鹅草稿生成进度'])( + 'hides batch module and keeps wait/timer in one row for %s', + (progressTitle) => { + render( + {}} + onEditSetting={() => {}} + onRetry={() => {}} + settingDescription={null} + settingActionLabel={null} + progressTitle={progressTitle} + />, + ); + + expect(screen.queryByText('当前批次')).toBeNull(); + expect(screen.getByText('预计等待')).toBeTruthy(); + expect(screen.getByText('计时')).toBeTruthy(); + + const statsNode = screen + .getByText('预计等待') + .closest('.custom-world-generation-stats'); + expect(statsNode?.className).toContain( + 'custom-world-generation-stats--two-column', + ); + expect(statsNode?.getAttribute('style')).toContain( + 'grid-template-columns: repeat(2, minmax(0, 1fr))', + ); + + const stepNodes = [ + screen.getByText('收集设定'), + screen.getByText('编译草稿'), + screen.getByText('写回结果'), + ].map((node) => node.closest('.custom-world-generation-step')); + + expect(stepNodes.every(Boolean)).toBe(true); + expect(stepNodes[0]?.getAttribute('style')).toContain( + '--generation-step-delay: 0ms', + ); + expect(stepNodes[1]?.getAttribute('style')).toContain( + '--generation-step-delay: 90ms', + ); + expect(stepNodes[2]?.getAttribute('style')).toContain( + '--generation-step-delay: 180ms', + ); + }, + ); + + test('keeps batch module for other generation pages', () => { + render( + {}} + onEditSetting={() => {}} + onRetry={() => {}} + settingDescription={null} + settingActionLabel={null} + progressTitle="大鱼吃小鱼草稿生成进度" + />, + ); + + expect(screen.getByText('当前批次')).toBeTruthy(); + expect( + screen + .getByText('预计等待') + .closest('.custom-world-generation-stats') + ?.className, + ).not.toContain('custom-world-generation-stats--two-column'); + }); +}); diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index 41189c00..70ff15d7 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -1,4 +1,6 @@ import { motion } from 'motion/react'; +import type { CSSProperties } from 'react'; +import { useEffect, useState } from 'react'; import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime'; import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress'; @@ -24,6 +26,7 @@ interface CustomWorldGenerationViewProps { pausedBadgeLabel?: string; idleBadgeLabel?: string; structuredEmptyText?: string; + hideBatchModule?: boolean; } function formatDuration(ms: number) { @@ -86,6 +89,49 @@ function buildFallbackRenderKey( return normalizedValue ? normalizedValue : fallback; } +function useIsMobileGenerationLayout() { + const [isMobile, setIsMobile] = useState(() => { + if ( + typeof window === 'undefined' || + typeof window.matchMedia !== 'function' + ) { + return false; + } + + return window.matchMedia('(max-width: 639px)').matches; + }); + + useEffect(() => { + if ( + typeof window === 'undefined' || + typeof window.matchMedia !== 'function' + ) { + return undefined; + } + + const mediaQuery = window.matchMedia('(max-width: 639px)'); + const syncMobileLayout = () => { + setIsMobile(mediaQuery.matches); + }; + + syncMobileLayout(); + + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', syncMobileLayout); + return () => { + mediaQuery.removeEventListener('change', syncMobileLayout); + }; + } + + mediaQuery.addListener(syncMobileLayout); + return () => { + mediaQuery.removeListener(syncMobileLayout); + }; + }, []); + + return isMobile; +} + export function CustomWorldGenerationView({ settingText, anchorEntries = [], @@ -107,7 +153,9 @@ export function CustomWorldGenerationView({ pausedBadgeLabel = '生成已暂停', idleBadgeLabel = '等待操作', structuredEmptyText = '正在整理当前设定结构,请稍后。', + hideBatchModule = false, }: CustomWorldGenerationViewProps) { + const isMobileGenerationLayout = useIsMobileGenerationLayout(); const progressValue = getProgressPercentage(progress); const steps = progress?.steps ?? []; const hasStructuredAnchors = anchorEntries.length > 0; @@ -116,6 +164,10 @@ export function CustomWorldGenerationView({ const normalizedSettingDescription = settingDescription?.trim() ?? ''; const hasSettingActionLabel = normalizedSettingActionLabel.length > 0; const hasSettingDescription = normalizedSettingDescription.length > 0; + const shouldHideBatchModule = + hideBatchModule || + progressTitle === '拼图草稿生成进度' || + progressTitle === '抓大鹅草稿生成进度'; const estimatedWaitText = progress?.estimatedRemainingMs != null ? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}` @@ -179,28 +231,41 @@ export function CustomWorldGenerationView({ /> -
-
-
- 当前批次 +
+ {shouldHideBatchModule ? null : ( +
+
+ 当前批次 +
+
+ {progress?.batchLabel ?? '准备中'} +
-
- {progress?.batchLabel ?? '准备中'} -
-
-
+ )} +
预计等待
-
+
{estimatedWaitText}
-
+
计时
-
+
{elapsedText}
@@ -211,7 +276,7 @@ export function CustomWorldGenerationView({ const stepProgress = getStepProgressPercentage(step); return ( -
@@ -248,7 +329,7 @@ export function CustomWorldGenerationView({
{step.detail}
-
+ ); })}
diff --git a/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx b/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx index 65e18de5..ac43de6a 100644 --- a/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx +++ b/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx @@ -12,7 +12,7 @@ describe('BarkBattleConfigEditor', () => { render(); expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy(); - expect(screen.getByText('轻配置作品')).toBeTruthy(); + expect(screen.getByText('轻配置')).toBeTruthy(); expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场'); expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal'); expect((screen.getByLabelText('开启排行榜') as HTMLInputElement).checked).toBe(true); @@ -47,4 +47,22 @@ describe('BarkBattleConfigEditor', () => { expect(onPublish).not.toHaveBeenCalled(); expect(screen.getByText('请先填写作品标题')).toBeTruthy(); }); + + it('can render as an embedded creation form without a local page header', () => { + const onPublish = vi.fn(); + render( + , + ); + + expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull(); + expect(screen.queryByRole('button', { name: '返回' })).toBeNull(); + expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy(); + expect(screen.getByText('发布失败')).toBeTruthy(); + }); }); diff --git a/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx b/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx index 0b5b4bf5..fd2345ff 100644 --- a/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx +++ b/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx @@ -1,3 +1,4 @@ +import { ArrowLeft, Loader2, Trophy, WandSparkles } from 'lucide-react'; import { useMemo, useState } from 'react'; import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle'; @@ -6,8 +7,11 @@ import { BarkBattlePreviewCard } from './BarkBattlePreviewCard'; export type BarkBattleConfigEditorProps = { isBusy?: boolean; + error?: string | null; onPublish: (payload: BarkBattleConfigEditorPayload) => void | Promise; onBack?: () => void; + showBackButton?: boolean; + title?: string | null; }; const THEME_OPTIONS = [ @@ -30,8 +34,11 @@ const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: stri export function BarkBattleConfigEditor({ isBusy = false, + error: externalError = null, onPublish, onBack, + showBackButton = true, + title: headingTitle = '汪汪声浪大作战', }: BarkBattleConfigEditorProps) { const [title, setTitle] = useState('我的声浪竞技场'); const [description, setDescription] = useState(''); @@ -40,7 +47,7 @@ export function BarkBattleConfigEditor({ const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('husky'); const [difficultyPreset, setDifficultyPreset] = useState('normal'); const [leaderboardEnabled, setLeaderboardEnabled] = useState(true); - const [error, setError] = useState(null); + const [localError, setLocalError] = useState(null); const payload = useMemo( () => ({ @@ -65,96 +72,212 @@ export function BarkBattleConfigEditor({ const handlePublish = () => { if (!payload.title) { - setError('请先填写作品标题'); + setLocalError('请先填写作品标题'); return; } - setError(null); + setLocalError(null); void onPublish(payload); }; + const visibleError = localError ?? externalError; return ( -
-
-
-
-
-

轻配置作品

-

汪汪声浪大作战

-

配置展示、皮肤、难度和排行榜;公平性规则由后端固定裁决。

+
+ {showBackButton && onBack ? ( +
+ +
+ ) : null} + +
+ {headingTitle ? ( +
+
+

+ {headingTitle} +

+ + 轻配置 +
- {onBack ? ( - +
+ ) : null} + +
+
+ + +