feat: integrate jump-hop shelf and asset flow
This commit is contained in:
@@ -118,7 +118,7 @@ npm run check:server-rs-ddd
|
|||||||
3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。
|
3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。
|
||||||
4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。
|
4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。
|
||||||
5. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。
|
5. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。
|
||||||
6. 拼图图生图参考图主链不得再把大图 Data URL 塞进创作 JSON body;前端先直传 OSS 并提交 `referenceImageAssetObjectId(s)`,`api-server` 校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取,Data URL / `/generated-*` 仅作为旧请求兼容。
|
6. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。
|
||||||
7. 系列素材图集使用 `server-rs/crates/api-server/src/generated_asset_sheets.rs`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。
|
7. 系列素材图集使用 `server-rs/crates/api-server/src/generated_asset_sheets.rs`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。
|
||||||
|
|
||||||
## SpacetimeDB schema 变更规则
|
## SpacetimeDB schema 变更规则
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ Windows Stdb module 构建流水线运行在 Jenkins `windows` 节点上。该
|
|||||||
- Windows 下载阶段如果出现 `curl: (18)` 或响应体截断,流水线会保留同名 `.download` 临时文件并用 `curl -C -` 断点续传;只有完整返回但 SHA256 digest 仍不匹配时才删除临时文件后重新下载。目标 Linux 节点仍只接收 `stash/unstash` 带过去的本地下载件,不回退外网下载。
|
- Windows 下载阶段如果出现 `curl: (18)` 或响应体截断,流水线会保留同名 `.download` 临时文件并用 `curl -C -` 断点续传;只有完整返回但 SHA256 digest 仍不匹配时才删除临时文件后重新下载。目标 Linux 节点仍只接收 `stash/unstash` 带过去的本地下载件,不回退外网下载。
|
||||||
- Windows 下载阶段如果走代理,在 `Genarrative-Server-Provision` 参数 `PROVISION_DOWNLOAD_PROXY` 填写 Windows Jenkins 节点可访问的 HTTP 代理,例如 `http://127.0.0.1:7890`;不要填写目标 release 机器视角的 `127.0.0.1`,除非代理确实运行在该 Windows 节点本机。Linux 目标机阶段会强制要求使用本地下载件,缺少文件直接失败,不再回退到外网下载。
|
- Windows 下载阶段如果走代理,在 `Genarrative-Server-Provision` 参数 `PROVISION_DOWNLOAD_PROXY` 填写 Windows Jenkins 节点可访问的 HTTP 代理,例如 `http://127.0.0.1:7890`;不要填写目标 release 机器视角的 `127.0.0.1`,除非代理确实运行在该 Windows 节点本机。Linux 目标机阶段会强制要求使用本地下载件,缺少文件直接失败,不再回退到外网下载。
|
||||||
- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。
|
- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。
|
||||||
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 只是反代兜底,防止旧客户端或兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;长期主链不得依赖大 JSON body 承载图片,拼图参考图应先直传 OSS,只向创作接口提交 `referenceImageAssetObjectId(s)`,由后端签只读 URL 给外部模型读取。真实业务上限仍由 Rust 路由 `DefaultBodyLimit`、资产确认时 OSS HEAD 和解码后字节校验控制。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否仍在提交 Data URL 而不是 `assetObjectId`。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。
|
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB,历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。
|
||||||
- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。
|
- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。
|
||||||
- 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅稳定、低基数、公开的专用投影,禁止订阅 `puzzle_work_profile`、`custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。
|
- 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅稳定、低基数、公开的专用投影,禁止订阅 `puzzle_work_profile`、`custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。
|
||||||
- 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。
|
- 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 >
|
|||||||
|
|
||||||
- 图像输入复用 `CreativeImageInputPanel`。
|
- 图像输入复用 `CreativeImageInputPanel`。
|
||||||
- 结果页每关画面编辑复用 `CreativeImageInputPanel`;入口页和关卡画面只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`。结果页删除独立“素材配置”Tab,不再提供单独 UI 背景生成入口。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在正式图自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。用户在本次编辑中上传或选择历史图后,该图优先占据主图卡片,可删除、切换 AI 重绘,也可关闭 AI 重绘直用;仅有正式图预览时,画面描述框仍可上传多张参考图。关卡详情弹窗应使用加宽面板,关卡名称、画面图和画面描述合并在同一个纵向列表中,名称输入和画面编辑模块外层不再包独立 `platform-subpanel`;画面图卡仍必须保留稳定最小高度,避免弹窗内 `flex-1` 布局坍缩后只剩标题、描述输入和操作按钮。
|
- 结果页每关画面编辑复用 `CreativeImageInputPanel`;入口页和关卡画面只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`。结果页删除独立“素材配置”Tab,不再提供单独 UI 背景生成入口。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在正式图自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。用户在本次编辑中上传或选择历史图后,该图优先占据主图卡片,可删除、切换 AI 重绘,也可关闭 AI 重绘直用;仅有正式图预览时,画面描述框仍可上传多张参考图。关卡详情弹窗应使用加宽面板,关卡名称、画面图和画面描述合并在同一个纵向列表中,名称输入和画面编辑模块外层不再包独立 `platform-subpanel`;画面图卡仍必须保留稳定最小高度,避免弹窗内 `flex-1` 布局坍缩后只剩标题、描述输入和操作按钮。
|
||||||
- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。
|
- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;入口页与结果页新增关卡本地上传都不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交给 `api-server`,并在图像输入区明确展示“图片≤6MB”。参考图上传前后统一限制为 6MB:前端读取前做文件大小校验并提示“参考图过大,请压缩后再上传(当前 X,最多 6MB)”,后端对 Data URL / asset object 的实际字节数再次检测并返回 400。历史图片仍提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part;本地上传 Data URL 与历史 `/generated-*` 路径继续由后端统一解析。关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。
|
||||||
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页进度不再按固定 5 分钟展示,而按实际开始时间和当前路径的分步骤预计时长推进;任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。
|
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页进度不再按固定 5 分钟展示,而按实际开始时间和当前路径的分步骤预计时长推进;任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。
|
||||||
- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。
|
- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。
|
||||||
- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次 `gpt-image-2` 调用的预期用时按 90 秒计算;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 90 秒、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 298 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。未收到 action 回包前,总进度仍最多停在 98%,但当预计写入时长耗尽且仍处于 `写入正式草稿` 时,该步骤自身应显示已完成,不能出现“进行中 100%”。
|
- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次 `gpt-image-2` 调用的预期用时按 90 秒计算;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 90 秒、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 298 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。未收到 action 回包前,总进度仍最多停在 98%,但当预计写入时长耗尽且仍处于 `写入正式草稿` 时,该步骤自身应显示已完成,不能出现“进行中 100%”。
|
||||||
@@ -120,6 +120,10 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 >
|
|||||||
|
|
||||||
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。
|
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。
|
||||||
|
|
||||||
|
跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。
|
||||||
|
|
||||||
|
删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。
|
||||||
|
|
||||||
## 敲木鱼
|
## 敲木鱼
|
||||||
|
|
||||||
对外名称:`敲木鱼`。工程域:`wooden-fish`。PRD 见 `docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。
|
对外名称:`敲木鱼`。工程域:`wooden-fish`。PRD 见 `docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface JumpHopWorkspaceCreateRequest {
|
|||||||
|
|
||||||
export interface JumpHopActionRequest {
|
export interface JumpHopActionRequest {
|
||||||
actionType: JumpHopActionType;
|
actionType: JumpHopActionType;
|
||||||
|
profileId?: string | null;
|
||||||
workTitle?: string | null;
|
workTitle?: string | null;
|
||||||
workDescription?: string | null;
|
workDescription?: string | null;
|
||||||
themeTags?: string[] | null;
|
themeTags?: string[] | null;
|
||||||
@@ -55,6 +56,10 @@ export interface JumpHopActionRequest {
|
|||||||
characterPrompt?: string | null;
|
characterPrompt?: string | null;
|
||||||
tilePrompt?: string | null;
|
tilePrompt?: string | null;
|
||||||
endMoodPrompt?: string | null;
|
endMoodPrompt?: string | null;
|
||||||
|
characterAsset?: JumpHopCharacterAsset | null;
|
||||||
|
tileAtlasAsset?: JumpHopCharacterAsset | null;
|
||||||
|
tileAssets?: JumpHopTileAsset[] | null;
|
||||||
|
coverComposite?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JumpHopCharacterAsset {
|
export interface JumpHopCharacterAsset {
|
||||||
|
|||||||
@@ -4,23 +4,44 @@ use axum::{
|
|||||||
http::{HeaderName, StatusCode, header},
|
http::{HeaderName, StatusCode, header},
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
|
use module_assets::{
|
||||||
|
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
|
||||||
|
generate_asset_binding_id, generate_asset_object_id,
|
||||||
|
};
|
||||||
|
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use shared_contracts::jump_hop::{
|
use shared_contracts::jump_hop::{
|
||||||
JumpHopActionRequest, JumpHopDraftResponse, JumpHopGalleryDetailResponse,
|
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
|
||||||
JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopRestartRunRequest,
|
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
|
||||||
JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse,
|
JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse,
|
||||||
JumpHopStartRunRequest, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType,
|
||||||
|
JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse,
|
||||||
JumpHopWorkspaceCreateRequest,
|
JumpHopWorkspaceCreateRequest,
|
||||||
};
|
};
|
||||||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||||
use spacetime_client::SpacetimeClientError;
|
use spacetime_client::SpacetimeClientError;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::{collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||||
|
generated_asset_sheets::{
|
||||||
|
GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt,
|
||||||
|
slice_generated_asset_sheet,
|
||||||
|
},
|
||||||
|
generated_image_assets::{
|
||||||
|
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||||||
|
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
|
||||||
|
normalize_generated_image_asset_mime,
|
||||||
|
},
|
||||||
|
openai_image_generation::{
|
||||||
|
build_openai_image_http_client, create_openai_image_generation,
|
||||||
|
require_openai_image_settings,
|
||||||
|
},
|
||||||
request_context::RequestContext, state::AppState,
|
request_context::RequestContext, state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] = ["start", "normal", "target", "finish", "bonus", "accent"];
|
||||||
|
|
||||||
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
||||||
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
|
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
|
||||||
const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
|
const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
|
||||||
@@ -103,6 +124,15 @@ pub async fn execute_jump_hop_action(
|
|||||||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||||||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
|
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
|
||||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||||
|
let mut payload = payload;
|
||||||
|
maybe_generate_jump_hop_assets(
|
||||||
|
&state,
|
||||||
|
&request_context,
|
||||||
|
session_id.as_str(),
|
||||||
|
owner_user_id.as_str(),
|
||||||
|
&mut payload,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let response = state
|
let response = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.execute_jump_hop_action(session_id, owner_user_id, payload)
|
.execute_jump_hop_action(session_id, owner_user_id, payload)
|
||||||
@@ -143,6 +173,31 @@ pub async fn publish_jump_hop_work(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_jump_hop_works(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
let works = state
|
||||||
|
.spacetime_client()
|
||||||
|
.list_jump_hop_works(authenticated.claims().user_id().to_string())
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
jump_hop_error_response(
|
||||||
|
&request_context,
|
||||||
|
JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
map_jump_hop_client_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(json_success_body(
|
||||||
|
Some(&request_context),
|
||||||
|
JumpHopWorksResponse {
|
||||||
|
items: works.into_iter().map(|work| work.summary).collect(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_jump_hop_runtime_work(
|
pub async fn get_jump_hop_runtime_work(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(profile_id): Path<String>,
|
Path(profile_id): Path<String>,
|
||||||
@@ -298,6 +353,336 @@ pub async fn get_jump_hop_gallery_detail(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn maybe_generate_jump_hop_assets(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
session_id: &str,
|
||||||
|
owner_user_id: &str,
|
||||||
|
payload: &mut JumpHopActionRequest,
|
||||||
|
) -> Result<(), Response> {
|
||||||
|
if !matches!(payload.action_type, JumpHopActionType::CompileDraft) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if payload.character_asset.is_some()
|
||||||
|
&& payload.tile_atlas_asset.is_some()
|
||||||
|
&& payload.tile_assets.as_ref().is_some_and(|assets| !assets.is_empty())
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let profile_id = payload
|
||||||
|
.profile_id
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.trim())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-"));
|
||||||
|
payload.profile_id = Some(profile_id.clone());
|
||||||
|
|
||||||
|
let settings = require_openai_image_settings(state)
|
||||||
|
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||||
|
let http_client = build_openai_image_http_client(&settings)
|
||||||
|
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||||
|
|
||||||
|
let character_prompt = payload
|
||||||
|
.character_prompt
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("俯视角可爱主角,透明背景");
|
||||||
|
let tile_prompt = payload
|
||||||
|
.tile_prompt
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("等距立体地块图集");
|
||||||
|
|
||||||
|
let character_generated = create_openai_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
character_prompt,
|
||||||
|
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
|
||||||
|
"1024*1024",
|
||||||
|
1,
|
||||||
|
&[],
|
||||||
|
"跳一跳角色资产生成失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||||
|
let character_image = character_generated.images.into_iter().next().ok_or_else(|| {
|
||||||
|
jump_hop_error_response(
|
||||||
|
request_context,
|
||||||
|
JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine",
|
||||||
|
"message": "跳一跳角色资产生成成功但未返回图片。",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let character_asset = persist_jump_hop_generated_image_asset(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
|
profile_id.as_str(),
|
||||||
|
"character",
|
||||||
|
character_prompt,
|
||||||
|
character_image,
|
||||||
|
LegacyAssetPrefix::JumpHopAssets,
|
||||||
|
768,
|
||||||
|
768,
|
||||||
|
request_context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let sheet_prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
|
||||||
|
subject_text: tile_prompt,
|
||||||
|
item_names: &vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()],
|
||||||
|
grid_size: 3,
|
||||||
|
item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视图"),
|
||||||
|
special_prompt: Some("每个格子对应一个 tile 类型,供跳一跳地块裁切使用。"),
|
||||||
|
})
|
||||||
|
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||||
|
let tile_generated = create_openai_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
sheet_prompt.as_str(),
|
||||||
|
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
|
||||||
|
"1024*1024",
|
||||||
|
1,
|
||||||
|
&[],
|
||||||
|
"跳一跳地块图集生成失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||||
|
let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| {
|
||||||
|
jump_hop_error_response(
|
||||||
|
request_context,
|
||||||
|
JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine",
|
||||||
|
"message": "跳一跳地块图集生成成功但未返回图片。",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let tile_slices = slice_generated_asset_sheet(
|
||||||
|
&tile_image,
|
||||||
|
&vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()],
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||||
|
let tile_atlas_asset = persist_jump_hop_generated_image_asset(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
|
profile_id.as_str(),
|
||||||
|
"tile-atlas",
|
||||||
|
tile_prompt,
|
||||||
|
tile_image,
|
||||||
|
LegacyAssetPrefix::JumpHopAssets,
|
||||||
|
1024,
|
||||||
|
1024,
|
||||||
|
request_context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let tile_assets = tile_slices
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, row)| JumpHopTileAsset {
|
||||||
|
tile_type: match index {
|
||||||
|
0 => JumpHopTileType::Start,
|
||||||
|
1 => JumpHopTileType::Normal,
|
||||||
|
2 => JumpHopTileType::Target,
|
||||||
|
3 => JumpHopTileType::Finish,
|
||||||
|
4 => JumpHopTileType::Bonus,
|
||||||
|
_ => JumpHopTileType::Accent,
|
||||||
|
},
|
||||||
|
image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
|
||||||
|
image_object_key: format!("generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
|
||||||
|
asset_object_id: format!("{profile_id}-tile-{index}-object"),
|
||||||
|
source_atlas_cell: format!("cell-{index}"),
|
||||||
|
visual_width: 256,
|
||||||
|
visual_height: 192,
|
||||||
|
top_surface_radius: 42.0,
|
||||||
|
landing_radius: 34.0,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
payload.character_asset = Some(character_asset);
|
||||||
|
payload.tile_atlas_asset = Some(tile_atlas_asset);
|
||||||
|
payload.tile_assets = Some(tile_assets);
|
||||||
|
payload.cover_composite = payload
|
||||||
|
.cover_composite
|
||||||
|
.clone()
|
||||||
|
.or_else(|| Some(format!("/generated-jump-hop-assets/{profile_id}/cover-composite.png")));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_jump_hop_generated_image_asset(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
profile_id: &str,
|
||||||
|
slot: &str,
|
||||||
|
prompt: &str,
|
||||||
|
image: crate::openai_image_generation::DownloadedOpenAiImage,
|
||||||
|
prefix: LegacyAssetPrefix,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
) -> Result<JumpHopCharacterAsset, Response> {
|
||||||
|
let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str());
|
||||||
|
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||||
|
prefix,
|
||||||
|
path_segments: vec![profile_id.to_string(), slot.to_string()],
|
||||||
|
file_stem: "image".to_string(),
|
||||||
|
image: GeneratedImageAssetDataUrl {
|
||||||
|
format: image_format,
|
||||||
|
bytes: image.bytes,
|
||||||
|
},
|
||||||
|
access: OssObjectAccess::Private,
|
||||||
|
metadata: GeneratedImageAssetAdapterMetadata {
|
||||||
|
asset_kind: Some(format!("jump-hop-{slot}")),
|
||||||
|
owner_user_id: Some(owner_user_id.to_string()),
|
||||||
|
entity_kind: Some("jump_hop_work".to_string()),
|
||||||
|
entity_id: Some(profile_id.to_string()),
|
||||||
|
slot: Some(slot.to_string()),
|
||||||
|
provider: Some("vector-engine".to_string()),
|
||||||
|
task_id: None,
|
||||||
|
},
|
||||||
|
extra_metadata: BTreeMap::new(),
|
||||||
|
})
|
||||||
|
.map_err(|error| {
|
||||||
|
jump_hop_error_response(
|
||||||
|
request_context,
|
||||||
|
JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||||
|
"provider": "generated-image-assets",
|
||||||
|
"message": format!("准备跳一跳图片资产上传请求失败:{error:?}"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let persisted_mime_type = prepared.format.mime_type.clone();
|
||||||
|
let oss_client = state.oss_client().ok_or_else(|| {
|
||||||
|
jump_hop_error_response(
|
||||||
|
request_context,
|
||||||
|
JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
|
"provider": "aliyun-oss",
|
||||||
|
"reason": "OSS 未完成环境变量配置",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let http_client = reqwest::Client::new();
|
||||||
|
let put_result = oss_client
|
||||||
|
.put_object(&http_client, prepared.request)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
jump_hop_error_response(
|
||||||
|
request_context,
|
||||||
|
JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "aliyun-oss",
|
||||||
|
"message": error.to_string(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let head = oss_client
|
||||||
|
.head_object(
|
||||||
|
&http_client,
|
||||||
|
OssHeadObjectRequest {
|
||||||
|
object_key: put_result.object_key.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
jump_hop_error_response(
|
||||||
|
request_context,
|
||||||
|
JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "aliyun-oss",
|
||||||
|
"message": error.to_string(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let now_micros = current_utc_micros();
|
||||||
|
let asset_object_input = build_asset_object_upsert_input(
|
||||||
|
generate_asset_object_id(now_micros),
|
||||||
|
head.bucket,
|
||||||
|
head.object_key.clone(),
|
||||||
|
AssetObjectAccessPolicy::Private,
|
||||||
|
head.content_type.or(Some(persisted_mime_type)),
|
||||||
|
head.content_length,
|
||||||
|
head.etag,
|
||||||
|
format!("jump-hop-{slot}"),
|
||||||
|
None,
|
||||||
|
Some(owner_user_id.to_string()),
|
||||||
|
Some(profile_id.to_string()),
|
||||||
|
Some(profile_id.to_string()),
|
||||||
|
now_micros,
|
||||||
|
)
|
||||||
|
.map_err(|error| {
|
||||||
|
jump_hop_error_response(
|
||||||
|
request_context,
|
||||||
|
JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "asset-object",
|
||||||
|
"message": error.to_string(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let asset_object = state
|
||||||
|
.spacetime_client()
|
||||||
|
.confirm_asset_object(asset_object_input)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
jump_hop_error_response(
|
||||||
|
request_context,
|
||||||
|
JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "spacetimedb",
|
||||||
|
"message": error.to_string(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let binding_input = build_asset_entity_binding_input(
|
||||||
|
generate_asset_binding_id(now_micros),
|
||||||
|
asset_object.asset_object_id.clone(),
|
||||||
|
"jump_hop_work".to_string(),
|
||||||
|
profile_id.to_string(),
|
||||||
|
slot.to_string(),
|
||||||
|
format!("jump-hop-{slot}"),
|
||||||
|
Some(owner_user_id.to_string()),
|
||||||
|
Some(profile_id.to_string()),
|
||||||
|
now_micros,
|
||||||
|
)
|
||||||
|
.map_err(|error| {
|
||||||
|
jump_hop_error_response(
|
||||||
|
request_context,
|
||||||
|
JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "asset-entity-binding",
|
||||||
|
"message": error.to_string(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
state
|
||||||
|
.spacetime_client()
|
||||||
|
.bind_asset_object_to_entity(binding_input)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
jump_hop_error_response(
|
||||||
|
request_context,
|
||||||
|
JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "spacetimedb",
|
||||||
|
"message": error.to_string(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(JumpHopCharacterAsset {
|
||||||
|
asset_id: format!("{profile_id}-{slot}-{now_micros}"),
|
||||||
|
image_src: put_result.legacy_public_path,
|
||||||
|
image_object_key: head.object_key,
|
||||||
|
asset_object_id: asset_object.asset_object_id,
|
||||||
|
generation_provider: "vector-engine".to_string(),
|
||||||
|
prompt: prompt.to_string(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
|
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
|
||||||
JumpHopDraftResponse {
|
JumpHopDraftResponse {
|
||||||
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::{
|
|||||||
jump_hop::{
|
jump_hop::{
|
||||||
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
|
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
|
||||||
get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery,
|
get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery,
|
||||||
publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
|
list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -36,6 +36,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
|||||||
require_bearer_auth,
|
require_bearer_auth,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/jump-hop/works",
|
||||||
|
get(list_jump_hop_works).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/creation/jump-hop/works/{profile_id}/publish",
|
"/api/creation/jump-hop/works/{profile_id}/publish",
|
||||||
post(publish_jump_hop_work).route_layer(middleware::from_fn_with_state(
|
post(publish_jump_hop_work).route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
@@ -122,12 +122,24 @@ const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
|
|||||||
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||||
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
||||||
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
|
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
|
||||||
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 6 * 1024 * 1024;
|
||||||
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
|
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
|
||||||
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
||||||
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
|
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
|
||||||
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
|
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
|
||||||
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
|
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
|
||||||
|
|
||||||
|
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
|
||||||
|
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_puzzle_reference_image_too_large_message(actual_bytes: usize) -> String {
|
||||||
|
format!(
|
||||||
|
"参考图过大,请压缩后再上传(当前 {},最多 6MB)。",
|
||||||
|
format_puzzle_reference_image_upload_bytes(actual_bytes)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const PUZZLE_LEVEL_SCENE_IMAGE_PROMPT: &str = "参考图作为拼图画面,生成对应的拼图游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n画面元素:\n返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 影”和倒计时时间,右上角显示设置按钮\n画面中间是一个正方形的3*3拼图,拼图区域宽度与画面宽度同宽,紧贴画面横向边缘,拼图区域边界带有边框装饰\n拼图区域下方包含一个下一关按钮,仅在关卡完成时显示\n底部是三个贴合画面主题的道具按钮分别为“提示”、“原图”、“冻结”\n道具按钮上不要显示次数标注,返回按钮和设置按钮旁禁止标注文字";
|
const PUZZLE_LEVEL_SCENE_IMAGE_PROMPT: &str = "参考图作为拼图画面,生成对应的拼图游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n画面元素:\n返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 影”和倒计时时间,右上角显示设置按钮\n画面中间是一个正方形的3*3拼图,拼图区域宽度与画面宽度同宽,紧贴画面横向边缘,拼图区域边界带有边框装饰\n拼图区域下方包含一个下一关按钮,仅在关卡完成时显示\n底部是三个贴合画面主题的道具按钮分别为“提示”、“原图”、“冻结”\n道具按钮上不要显示次数标注,返回按钮和设置按钮旁禁止标注文字";
|
||||||
const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "提取画面中的UI元素,将返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮整理成纯绿色绿幕背景的spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。按钮顺序必须按原图位置从左到右、从上到下排列:返回、设置、下一关、提示、原图、冻结。按钮素材内必须保留对应中文文字,每个按钮必须是独立完整图形,按钮之间保留足够纯绿色绿幕空白,不能相互接触、重叠或连成一片,方便运行态按自动边界检测识别矩形素材。返回按钮和设置按钮不要额外画白色外圈、白底圆环或浮雕外框,直接画扁平图标本体。按钮自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。禁止水印、数字、次数标注、透明背景、背景图、拼图块、棋盘、网格线、按钮外标签和额外按钮。";
|
const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "提取画面中的UI元素,将返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮整理成纯绿色绿幕背景的spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。按钮顺序必须按原图位置从左到右、从上到下排列:返回、设置、下一关、提示、原图、冻结。按钮素材内必须保留对应中文文字,每个按钮必须是独立完整图形,按钮之间保留足够纯绿色绿幕空白,不能相互接触、重叠或连成一片,方便运行态按自动边界检测识别矩形素材。返回按钮和设置按钮不要额外画白色外圈、白底圆环或浮雕外框,直接画扁平图标本体。按钮自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。禁止水印、数字、次数标注、透明背景、背景图、拼图块、棋盘、网格线、按钮外标签和额外按钮。";
|
||||||
const PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT: &str = "移除参考图中所有UI元素、移除拼图画面,仅保留背景图,补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容";
|
const PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT: &str = "移除参考图中所有UI元素、移除拼图画面,仅保留背景图,补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容";
|
||||||
|
|||||||
@@ -643,15 +643,13 @@ pub(crate) async fn resolve_puzzle_reference_image(
|
|||||||
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
||||||
let bytes_len = parsed.bytes.len();
|
let bytes_len = parsed.bytes.len();
|
||||||
if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES {
|
if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES {
|
||||||
return Err(
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
"provider": "puzzle",
|
||||||
"provider": "puzzle",
|
"field": "referenceImageSrc",
|
||||||
"field": "referenceImageSrc",
|
"message": build_puzzle_reference_image_too_large_message(bytes_len),
|
||||||
"message": "参考图过大,请压缩后重试。",
|
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
"actualBytes": bytes_len,
|
||||||
"actualBytes": bytes_len,
|
})));
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Ok(PuzzleResolvedReferenceImage {
|
return Ok(PuzzleResolvedReferenceImage {
|
||||||
mime_type: parsed.mime_type,
|
mime_type: parsed.mime_type,
|
||||||
@@ -803,16 +801,16 @@ pub(crate) fn validate_puzzle_reference_asset_object(
|
|||||||
if asset_object.content_length == 0
|
if asset_object.content_length == 0
|
||||||
|| asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64
|
|| asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64
|
||||||
{
|
{
|
||||||
return Err(
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
"provider": "asset-object",
|
||||||
"provider": "asset-object",
|
"field": "referenceImageAssetObjectId",
|
||||||
"field": "referenceImageAssetObjectId",
|
"assetObjectId": asset_object.asset_object_id,
|
||||||
"assetObjectId": asset_object.asset_object_id,
|
"message": build_puzzle_reference_image_too_large_message(
|
||||||
"message": "参考图资产大小不符合拼图生成要求。",
|
asset_object.content_length as usize,
|
||||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
),
|
||||||
"actualBytes": asset_object.content_length,
|
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||||
})),
|
"actualBytes": asset_object.content_length,
|
||||||
);
|
})));
|
||||||
}
|
}
|
||||||
if let Some(expected_owner_user_id) = owner_user_id
|
if let Some(expected_owner_user_id) = owner_user_id
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const OSS_V4_REQUEST: &str = "aliyun_v4_request";
|
|||||||
const OSS_V4_SERVICE: &str = "oss";
|
const OSS_V4_SERVICE: &str = "oss";
|
||||||
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
||||||
|
|
||||||
pub const LEGACY_PUBLIC_PREFIXES: [&str; 12] = [
|
pub const LEGACY_PUBLIC_PREFIXES: [&str; 13] = [
|
||||||
"generated-character-drafts",
|
"generated-character-drafts",
|
||||||
"generated-characters",
|
"generated-characters",
|
||||||
"generated-animations",
|
"generated-animations",
|
||||||
@@ -29,6 +29,7 @@ pub const LEGACY_PUBLIC_PREFIXES: [&str; 12] = [
|
|||||||
"generated-wooden-fish-assets",
|
"generated-wooden-fish-assets",
|
||||||
"generated-match3d-assets",
|
"generated-match3d-assets",
|
||||||
"generated-puzzle-assets",
|
"generated-puzzle-assets",
|
||||||
|
"generated-jump-hop-assets",
|
||||||
"generated-custom-world-scenes",
|
"generated-custom-world-scenes",
|
||||||
"generated-custom-world-covers",
|
"generated-custom-world-covers",
|
||||||
"generated-bark-battle-assets",
|
"generated-bark-battle-assets",
|
||||||
@@ -52,6 +53,7 @@ pub enum LegacyAssetPrefix {
|
|||||||
WoodenFishAssets,
|
WoodenFishAssets,
|
||||||
Match3DAssets,
|
Match3DAssets,
|
||||||
PuzzleAssets,
|
PuzzleAssets,
|
||||||
|
JumpHopAssets,
|
||||||
CustomWorldScenes,
|
CustomWorldScenes,
|
||||||
CustomWorldCovers,
|
CustomWorldCovers,
|
||||||
BarkBattleAssets,
|
BarkBattleAssets,
|
||||||
@@ -241,6 +243,7 @@ impl LegacyAssetPrefix {
|
|||||||
"generated-wooden-fish-assets" => Some(Self::WoodenFishAssets),
|
"generated-wooden-fish-assets" => Some(Self::WoodenFishAssets),
|
||||||
"generated-match3d-assets" => Some(Self::Match3DAssets),
|
"generated-match3d-assets" => Some(Self::Match3DAssets),
|
||||||
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
|
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
|
||||||
|
"generated-jump-hop-assets" => Some(Self::JumpHopAssets),
|
||||||
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
|
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
|
||||||
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
|
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
|
||||||
"generated-bark-battle-assets" => Some(Self::BarkBattleAssets),
|
"generated-bark-battle-assets" => Some(Self::BarkBattleAssets),
|
||||||
@@ -259,6 +262,7 @@ impl LegacyAssetPrefix {
|
|||||||
Self::WoodenFishAssets => "generated-wooden-fish-assets",
|
Self::WoodenFishAssets => "generated-wooden-fish-assets",
|
||||||
Self::Match3DAssets => "generated-match3d-assets",
|
Self::Match3DAssets => "generated-match3d-assets",
|
||||||
Self::PuzzleAssets => "generated-puzzle-assets",
|
Self::PuzzleAssets => "generated-puzzle-assets",
|
||||||
|
Self::JumpHopAssets => "generated-jump-hop-assets",
|
||||||
Self::CustomWorldScenes => "generated-custom-world-scenes",
|
Self::CustomWorldScenes => "generated-custom-world-scenes",
|
||||||
Self::CustomWorldCovers => "generated-custom-world-covers",
|
Self::CustomWorldCovers => "generated-custom-world-covers",
|
||||||
Self::BarkBattleAssets => "generated-bark-battle-assets",
|
Self::BarkBattleAssets => "generated-bark-battle-assets",
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ pub struct JumpHopWorkspaceCreateRequest {
|
|||||||
pub struct JumpHopActionRequest {
|
pub struct JumpHopActionRequest {
|
||||||
pub action_type: JumpHopActionType,
|
pub action_type: JumpHopActionType,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub profile_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub work_title: Option<String>,
|
pub work_title: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub work_description: Option<String>,
|
pub work_description: Option<String>,
|
||||||
@@ -102,6 +104,14 @@ pub struct JumpHopActionRequest {
|
|||||||
pub tile_prompt: Option<String>,
|
pub tile_prompt: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub end_mood_prompt: Option<String>,
|
pub end_mood_prompt: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub character_asset: Option<JumpHopCharacterAsset>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tile_atlas_asset: Option<JumpHopCharacterAsset>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tile_assets: Option<Vec<JumpHopTileAsset>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cover_composite: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
|||||||
@@ -226,8 +226,11 @@ impl SpacetimeClient {
|
|||||||
&self,
|
&self,
|
||||||
profile_id: String,
|
profile_id: String,
|
||||||
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
|
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
|
||||||
self.get_jump_hop_work_profile(profile_id, String::new())
|
let work = self
|
||||||
.await
|
.get_jump_hop_work_profile(profile_id, String::new())
|
||||||
|
.await?;
|
||||||
|
validate_jump_hop_runtime_ready(&work)?;
|
||||||
|
Ok(work)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_jump_hop_run(
|
pub async fn start_jump_hop_run(
|
||||||
@@ -235,12 +238,17 @@ impl SpacetimeClient {
|
|||||||
payload: JumpHopStartRunRequest,
|
payload: JumpHopStartRunRequest,
|
||||||
owner_user_id: String,
|
owner_user_id: String,
|
||||||
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
|
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
|
||||||
|
let profile_id = payload.profile_id;
|
||||||
|
let work = self
|
||||||
|
.get_jump_hop_work_profile(profile_id.clone(), String::new())
|
||||||
|
.await?;
|
||||||
|
validate_jump_hop_runtime_ready(&work)?;
|
||||||
let run_id = build_prefixed_uuid_id("jump-hop-run-");
|
let run_id = build_prefixed_uuid_id("jump-hop-run-");
|
||||||
let procedure_input = JumpHopRunStartInput {
|
let procedure_input = JumpHopRunStartInput {
|
||||||
client_event_id: format!("{run_id}:start"),
|
client_event_id: format!("{run_id}:start"),
|
||||||
run_id,
|
run_id,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
profile_id: payload.profile_id,
|
profile_id,
|
||||||
started_at_ms: current_unix_micros().div_euclid(1000),
|
started_at_ms: current_unix_micros().div_euclid(1000),
|
||||||
};
|
};
|
||||||
self.start_jump_hop_run_with_input(procedure_input).await
|
self.start_jump_hop_run_with_input(procedure_input).await
|
||||||
@@ -372,11 +380,91 @@ impl SpacetimeClient {
|
|||||||
&self,
|
&self,
|
||||||
public_work_code: String,
|
public_work_code: String,
|
||||||
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
|
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
|
||||||
self.get_jump_hop_work_profile(public_work_code, String::new())
|
let gallery = self.list_jump_hop_gallery().await?;
|
||||||
|
let requested_code = normalize_jump_hop_public_work_code(public_work_code.as_str());
|
||||||
|
let card = gallery
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.find(|item| {
|
||||||
|
normalize_jump_hop_public_work_code(item.public_work_code.as_str()) == requested_code
|
||||||
|
})
|
||||||
|
.ok_or_else(|| SpacetimeClientError::Procedure("jump_hop public work 不存在".to_string()))?;
|
||||||
|
|
||||||
|
self.get_jump_hop_work_profile(card.profile_id, String::new())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn validate_jump_hop_runtime_ready(
|
||||||
|
work: &JumpHopWorkProfileResponse,
|
||||||
|
) -> Result<(), SpacetimeClientError> {
|
||||||
|
let status = work.summary.publication_status.trim().to_ascii_lowercase();
|
||||||
|
if status != "published" {
|
||||||
|
return Err(SpacetimeClientError::validation_failed(
|
||||||
|
"jump-hop runtime 只能启动已发布作品",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if work.summary.generation_status != JumpHopGenerationStatus::Ready {
|
||||||
|
return Err(SpacetimeClientError::validation_failed(
|
||||||
|
"jump-hop runtime 需要 ready 状态作品",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?;
|
||||||
|
validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
|
||||||
|
if work.tile_assets.is_empty() {
|
||||||
|
return Err(SpacetimeClientError::validation_failed(
|
||||||
|
"jump-hop runtime 缺少地块资产",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (index, asset) in work.tile_assets.iter().enumerate() {
|
||||||
|
if asset.image_src.trim().is_empty()
|
||||||
|
|| asset.image_object_key.trim().is_empty()
|
||||||
|
|| asset.asset_object_id.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err(SpacetimeClientError::validation_failed(format!(
|
||||||
|
"jump-hop runtime 地块资产 #{index} 不完整"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if work.path.platforms.is_empty() {
|
||||||
|
return Err(SpacetimeClientError::validation_failed(
|
||||||
|
"jump-hop runtime 缺少可玩路径",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_jump_hop_character_asset_ready(
|
||||||
|
asset: &JumpHopCharacterAsset,
|
||||||
|
field: &str,
|
||||||
|
) -> Result<(), SpacetimeClientError> {
|
||||||
|
if asset.image_src.trim().is_empty()
|
||||||
|
|| asset.image_object_key.trim().is_empty()
|
||||||
|
|| asset.asset_object_id.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err(SpacetimeClientError::validation_failed(format!(
|
||||||
|
"jump-hop runtime {field} 不完整"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if asset.generation_provider.trim().is_empty()
|
||||||
|
|| asset.generation_provider == "deterministic-placeholder"
|
||||||
|
{
|
||||||
|
return Err(SpacetimeClientError::validation_failed(format!(
|
||||||
|
"jump-hop runtime {field} 不是可用真实生成资产"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_jump_hop_public_work_code(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter(|character| character.is_ascii_alphanumeric())
|
||||||
|
.map(|character| character.to_ascii_uppercase())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
enum JumpHopActionProcedure {
|
enum JumpHopActionProcedure {
|
||||||
Compile(JumpHopDraftCompileInput),
|
Compile(JumpHopDraftCompileInput),
|
||||||
Update(JumpHopWorkUpdateInput),
|
Update(JumpHopWorkUpdateInput),
|
||||||
@@ -503,22 +591,61 @@ fn merge_action_into_draft(
|
|||||||
if matches!(
|
if matches!(
|
||||||
scope,
|
scope,
|
||||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
||||||
) && let Some(value) = payload
|
) {
|
||||||
.character_prompt
|
if let Some(value) = payload
|
||||||
.as_ref()
|
.character_prompt
|
||||||
.filter(|value| !value.trim().is_empty())
|
.as_ref()
|
||||||
{
|
.filter(|value| !value.trim().is_empty())
|
||||||
draft.character_prompt = value.trim().to_string();
|
{
|
||||||
|
draft.character_prompt = value.trim().to_string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
scope,
|
scope,
|
||||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
|
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
|
||||||
) && let Some(value) = payload
|
) {
|
||||||
.tile_prompt
|
if let Some(value) = payload
|
||||||
|
.tile_prompt
|
||||||
|
.as_ref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
draft.tile_prompt = value.trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(profile_id) = payload
|
||||||
|
.profile_id
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.map(|value| value.trim())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
{
|
{
|
||||||
draft.tile_prompt = value.trim().to_string();
|
draft.profile_id = Some(profile_id.to_string());
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
scope,
|
||||||
|
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
||||||
|
) {
|
||||||
|
if let Some(asset) = payload.character_asset.clone() {
|
||||||
|
draft.character_asset = Some(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
scope,
|
||||||
|
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
|
||||||
|
) {
|
||||||
|
if let Some(asset) = payload.tile_atlas_asset.clone() {
|
||||||
|
draft.tile_atlas_asset = Some(asset);
|
||||||
|
}
|
||||||
|
if let Some(assets) = payload.tile_assets.clone() {
|
||||||
|
draft.tile_assets = assets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = payload
|
||||||
|
.cover_composite
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.trim())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
draft.cover_composite = Some(value.to_string());
|
||||||
}
|
}
|
||||||
if draft.work_title.trim().is_empty() {
|
if draft.work_title.trim().is_empty() {
|
||||||
return Err(SpacetimeClientError::validation_failed(
|
return Err(SpacetimeClientError::validation_failed(
|
||||||
@@ -545,31 +672,30 @@ fn build_compile_input(
|
|||||||
draft.tile_atlas_asset = None;
|
draft.tile_atlas_asset = None;
|
||||||
draft.tile_assets.clear();
|
draft.tile_assets.clear();
|
||||||
}
|
}
|
||||||
let character_asset = ensure_character_asset(
|
let character_asset = draft.character_asset.clone().ok_or_else(|| {
|
||||||
draft.character_asset.clone(),
|
SpacetimeClientError::validation_failed(
|
||||||
|
"jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生成并持久化 asset_object",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| {
|
||||||
|
SpacetimeClientError::validation_failed(
|
||||||
|
"jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let tile_assets = if draft.tile_assets.is_empty() {
|
||||||
|
return Err(SpacetimeClientError::validation_failed(
|
||||||
|
"jump-hop compile-draft 缺少真实地块资产,请先由 api-server 生成并持久化 asset_object",
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
draft.tile_assets.clone()
|
||||||
|
};
|
||||||
|
let cover_composite = resolve_cover_composite(
|
||||||
|
draft,
|
||||||
profile_id,
|
profile_id,
|
||||||
&draft.character_prompt,
|
refresh,
|
||||||
force_character,
|
|
||||||
now_micros,
|
now_micros,
|
||||||
);
|
);
|
||||||
let tile_atlas_asset = ensure_tile_atlas_asset(
|
|
||||||
draft.tile_atlas_asset.clone(),
|
|
||||||
profile_id,
|
|
||||||
&draft.tile_prompt,
|
|
||||||
force_tiles,
|
|
||||||
now_micros,
|
|
||||||
);
|
|
||||||
let tile_assets = ensure_tile_assets(
|
|
||||||
draft.tile_assets.clone(),
|
|
||||||
profile_id,
|
|
||||||
force_tiles,
|
|
||||||
now_micros,
|
|
||||||
);
|
|
||||||
let cover_composite = resolve_cover_composite(draft, profile_id, refresh, now_micros);
|
|
||||||
|
|
||||||
draft.character_asset = Some(character_asset.clone());
|
|
||||||
draft.tile_atlas_asset = Some(tile_atlas_asset.clone());
|
|
||||||
draft.tile_assets = tile_assets.clone();
|
|
||||||
draft.cover_composite = cover_composite.clone();
|
draft.cover_composite = cover_composite.clone();
|
||||||
draft.generation_status = JumpHopGenerationStatus::Ready;
|
draft.generation_status = JumpHopGenerationStatus::Ready;
|
||||||
|
|
||||||
@@ -698,8 +824,10 @@ fn ensure_character_asset(
|
|||||||
force_new: bool,
|
force_new: bool,
|
||||||
now_micros: i64,
|
now_micros: i64,
|
||||||
) -> JumpHopCharacterAsset {
|
) -> JumpHopCharacterAsset {
|
||||||
if !force_new && let Some(asset) = existing {
|
if !force_new {
|
||||||
return asset;
|
if let Some(asset) = existing {
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let revision = force_new.then_some(now_micros);
|
let revision = force_new.then_some(now_micros);
|
||||||
let suffix = asset_revision_suffix(revision);
|
let suffix = asset_revision_suffix(revision);
|
||||||
@@ -722,8 +850,10 @@ fn ensure_tile_atlas_asset(
|
|||||||
force_new: bool,
|
force_new: bool,
|
||||||
now_micros: i64,
|
now_micros: i64,
|
||||||
) -> JumpHopCharacterAsset {
|
) -> JumpHopCharacterAsset {
|
||||||
if !force_new && let Some(asset) = existing {
|
if !force_new {
|
||||||
return asset;
|
if let Some(asset) = existing {
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let revision = force_new.then_some(now_micros);
|
let revision = force_new.then_some(now_micros);
|
||||||
let suffix = asset_revision_suffix(revision);
|
let suffix = asset_revision_suffix(revision);
|
||||||
@@ -781,14 +911,15 @@ fn resolve_cover_composite(
|
|||||||
refresh: JumpHopAssetRefresh,
|
refresh: JumpHopAssetRefresh,
|
||||||
now_micros: i64,
|
now_micros: i64,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
if matches!(refresh, JumpHopAssetRefresh::Preserve)
|
if matches!(refresh, JumpHopAssetRefresh::Preserve) {
|
||||||
&& let Some(value) = draft
|
if let Some(value) = draft
|
||||||
.cover_composite
|
.cover_composite
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|value| value.trim())
|
.map(|value| value.trim())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
{
|
{
|
||||||
return Some(value.to_string());
|
return Some(value.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let suffix = asset_revision_suffix(
|
let suffix = asset_revision_suffix(
|
||||||
(!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros),
|
(!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros),
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec<JumpHopGall
|
|||||||
jump_hop_gallery_view(ctx)
|
jump_hop_gallery_view(ctx)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| JumpHopGalleryCardViewRow {
|
.map(|row| JumpHopGalleryCardViewRow {
|
||||||
public_work_code: row.work_id.clone(),
|
public_work_code: build_jump_hop_public_work_code(&row.profile_id),
|
||||||
work_id: row.work_id,
|
work_id: row.work_id,
|
||||||
profile_id: row.profile_id,
|
profile_id: row.profile_id,
|
||||||
owner_user_id: row.owner_user_id,
|
owner_user_id: row.owner_user_id,
|
||||||
@@ -658,6 +658,25 @@ fn build_gallery_view_row(row: &JumpHopWorkProfileRow) -> Result<JumpHopGalleryV
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_jump_hop_public_work_code(profile_id: &str) -> String {
|
||||||
|
let normalized = profile_id
|
||||||
|
.chars()
|
||||||
|
.filter(|character| character.is_ascii_alphanumeric())
|
||||||
|
.flat_map(|character| character.to_uppercase())
|
||||||
|
.collect::<String>();
|
||||||
|
let fallback = if normalized.is_empty() {
|
||||||
|
"00000000".to_string()
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
};
|
||||||
|
let suffix = if fallback.len() > 8 {
|
||||||
|
fallback[fallback.len() - 8..].to_string()
|
||||||
|
} else {
|
||||||
|
format!("{fallback:0>8}")
|
||||||
|
};
|
||||||
|
format!("JH-{suffix}")
|
||||||
|
}
|
||||||
|
|
||||||
fn build_session_snapshot(
|
fn build_session_snapshot(
|
||||||
row: &JumpHopAgentSessionRow,
|
row: &JumpHopAgentSessionRow,
|
||||||
) -> Result<JumpHopAgentSessionSnapshot, String> {
|
) -> Result<JumpHopAgentSessionSnapshot, String> {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export type CreativeImageInputPanelProps = {
|
|||||||
aiRedraw: boolean;
|
aiRedraw: boolean;
|
||||||
promptReferenceImages: CreativeImageInputReferenceImage[];
|
promptReferenceImages: CreativeImageInputReferenceImage[];
|
||||||
promptReferenceLimit?: number;
|
promptReferenceLimit?: number;
|
||||||
|
imageLimitHint?: string | null;
|
||||||
imageModelPicker?: ReactNode;
|
imageModelPicker?: ReactNode;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
inputError?: string | null;
|
inputError?: string | null;
|
||||||
@@ -95,6 +96,7 @@ export function CreativeImageInputPanel({
|
|||||||
aiRedraw,
|
aiRedraw,
|
||||||
promptReferenceImages,
|
promptReferenceImages,
|
||||||
promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT,
|
promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT,
|
||||||
|
imageLimitHint = null,
|
||||||
imageModelPicker = null,
|
imageModelPicker = null,
|
||||||
error = null,
|
error = null,
|
||||||
inputError = null,
|
inputError = null,
|
||||||
@@ -274,6 +276,11 @@ export function CreativeImageInputPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{mainImageMeta ? <div className="mt-3 shrink-0">{mainImageMeta}</div> : null}
|
{mainImageMeta ? <div className="mt-3 shrink-0">{mainImageMeta}</div> : null}
|
||||||
|
{imageLimitHint ? (
|
||||||
|
<div className="mt-2 shrink-0 text-center text-[11px] font-semibold text-[var(--platform-text-soft)]">
|
||||||
|
{imageLimitHint}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPrompt ? (
|
{showPrompt ? (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contra
|
|||||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
|
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
@@ -61,6 +62,9 @@ type CustomWorldCreationHubProps = {
|
|||||||
squareHoleItems?: SquareHoleWorkSummary[];
|
squareHoleItems?: SquareHoleWorkSummary[];
|
||||||
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
|
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
|
||||||
onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null;
|
onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null;
|
||||||
|
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
||||||
|
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
||||||
|
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
|
||||||
puzzleItems?: PuzzleWorkSummary[];
|
puzzleItems?: PuzzleWorkSummary[];
|
||||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||||
@@ -169,6 +173,9 @@ export function CustomWorldCreationHub({
|
|||||||
squareHoleItems = [],
|
squareHoleItems = [],
|
||||||
onOpenSquareHoleDetail,
|
onOpenSquareHoleDetail,
|
||||||
onDeleteSquareHole = null,
|
onDeleteSquareHole = null,
|
||||||
|
jumpHopItems = [],
|
||||||
|
onOpenJumpHopDetail,
|
||||||
|
onDeleteJumpHop = null,
|
||||||
puzzleItems = [],
|
puzzleItems = [],
|
||||||
onOpenPuzzleDetail,
|
onOpenPuzzleDetail,
|
||||||
onDeletePuzzle = null,
|
onDeletePuzzle = null,
|
||||||
@@ -201,6 +208,7 @@ export function CustomWorldCreationHub({
|
|||||||
bigFishItems,
|
bigFishItems,
|
||||||
match3dItems,
|
match3dItems,
|
||||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
|
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
|
||||||
|
jumpHopItems,
|
||||||
puzzleItems,
|
puzzleItems,
|
||||||
babyObjectMatchItems,
|
babyObjectMatchItems,
|
||||||
barkBattleItems,
|
barkBattleItems,
|
||||||
@@ -210,6 +218,7 @@ export function CustomWorldCreationHub({
|
|||||||
canDeleteMatch3D: Boolean(onDeleteMatch3D),
|
canDeleteMatch3D: Boolean(onDeleteMatch3D),
|
||||||
canDeleteSquareHole:
|
canDeleteSquareHole:
|
||||||
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
|
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
|
||||||
|
canDeleteJumpHop: Boolean(onDeleteJumpHop),
|
||||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||||
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
|
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
|
||||||
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
|
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
|
||||||
@@ -223,6 +232,8 @@ export function CustomWorldCreationHub({
|
|||||||
onDeleteMatch3D: onDeleteMatch3D ?? undefined,
|
onDeleteMatch3D: onDeleteMatch3D ?? undefined,
|
||||||
onOpenSquareHoleDetail,
|
onOpenSquareHoleDetail,
|
||||||
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
|
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
|
||||||
|
onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined,
|
||||||
|
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
|
||||||
onOpenPuzzleDetail,
|
onOpenPuzzleDetail,
|
||||||
onDeletePuzzle: onDeletePuzzle ?? undefined,
|
onDeletePuzzle: onDeletePuzzle ?? undefined,
|
||||||
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
|
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
|
||||||
@@ -249,6 +260,7 @@ export function CustomWorldCreationHub({
|
|||||||
onDeleteBabyObjectMatch,
|
onDeleteBabyObjectMatch,
|
||||||
onDeleteBarkBattle,
|
onDeleteBarkBattle,
|
||||||
onDeleteVisualNovel,
|
onDeleteVisualNovel,
|
||||||
|
onDeleteJumpHop,
|
||||||
onClaimPuzzlePointIncentive,
|
onClaimPuzzlePointIncentive,
|
||||||
onOpenBigFishDetail,
|
onOpenBigFishDetail,
|
||||||
onOpenDraft,
|
onOpenDraft,
|
||||||
@@ -262,7 +274,9 @@ export function CustomWorldCreationHub({
|
|||||||
getWorkState,
|
getWorkState,
|
||||||
puzzleItems,
|
puzzleItems,
|
||||||
rpgLibraryEntries,
|
rpgLibraryEntries,
|
||||||
squareHoleItems,
|
onOpenSquareHoleDetail,
|
||||||
|
onOpenJumpHopDetail,
|
||||||
|
jumpHopItems,
|
||||||
visualNovelItems,
|
visualNovelItems,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -310,6 +324,9 @@ export function CustomWorldCreationHub({
|
|||||||
case 'square-hole':
|
case 'square-hole':
|
||||||
onOpenSquareHoleDetail?.(item.source.item);
|
onOpenSquareHoleDetail?.(item.source.item);
|
||||||
return;
|
return;
|
||||||
|
case 'jump-hop':
|
||||||
|
onOpenJumpHopDetail?.(item.source.item);
|
||||||
|
return;
|
||||||
case 'rpg':
|
case 'rpg':
|
||||||
if (item.status === 'draft') {
|
if (item.status === 'draft') {
|
||||||
onOpenDraft(item.source.item);
|
onOpenDraft(item.source.item);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
|
|||||||
'big-fish': '/creation-type-references/big-fish.webp',
|
'big-fish': '/creation-type-references/big-fish.webp',
|
||||||
match3d: '/creation-type-references/match3d.webp',
|
match3d: '/creation-type-references/match3d.webp',
|
||||||
'square-hole': '/creation-type-references/square-hole.webp',
|
'square-hole': '/creation-type-references/square-hole.webp',
|
||||||
|
'jump-hop': '/creation-type-references/jump-hop.webp',
|
||||||
puzzle: '/creation-type-references/puzzle.webp',
|
puzzle: '/creation-type-references/puzzle.webp',
|
||||||
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
||||||
'bark-battle': '/creation-type-references/bark-battle.webp',
|
'bark-battle': '/creation-type-references/bark-battle.webp',
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
|
|||||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
|
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||||
import {
|
import {
|
||||||
buildBabyObjectMatchPublicWorkCode,
|
buildBabyObjectMatchPublicWorkCode,
|
||||||
buildBarkBattlePublicWorkCode,
|
buildBarkBattlePublicWorkCode,
|
||||||
buildBigFishPublicWorkCode,
|
buildBigFishPublicWorkCode,
|
||||||
|
buildJumpHopPublicWorkCode,
|
||||||
buildMatch3DPublicWorkCode,
|
buildMatch3DPublicWorkCode,
|
||||||
buildPuzzlePublicWorkCode,
|
buildPuzzlePublicWorkCode,
|
||||||
buildSquareHolePublicWorkCode,
|
buildSquareHolePublicWorkCode,
|
||||||
@@ -30,6 +32,7 @@ export type CreationWorkShelfKind =
|
|||||||
| 'big-fish'
|
| 'big-fish'
|
||||||
| 'match3d'
|
| 'match3d'
|
||||||
| 'square-hole'
|
| 'square-hole'
|
||||||
|
| 'jump-hop'
|
||||||
| 'puzzle'
|
| 'puzzle'
|
||||||
| 'baby-object-match'
|
| 'baby-object-match'
|
||||||
| 'bark-battle'
|
| 'bark-battle'
|
||||||
@@ -82,6 +85,10 @@ export type CreationWorkShelfSource =
|
|||||||
kind: 'square-hole';
|
kind: 'square-hole';
|
||||||
item: SquareHoleWorkSummary;
|
item: SquareHoleWorkSummary;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
kind: 'jump-hop';
|
||||||
|
item: JumpHopWorkSummaryResponse;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'puzzle';
|
kind: 'puzzle';
|
||||||
item: PuzzleWorkSummary;
|
item: PuzzleWorkSummary;
|
||||||
@@ -136,6 +143,7 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
bigFishItems: BigFishWorkSummary[];
|
bigFishItems: BigFishWorkSummary[];
|
||||||
match3dItems?: Match3DWorkSummary[];
|
match3dItems?: Match3DWorkSummary[];
|
||||||
squareHoleItems?: SquareHoleWorkSummary[];
|
squareHoleItems?: SquareHoleWorkSummary[];
|
||||||
|
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
||||||
puzzleItems: PuzzleWorkSummary[];
|
puzzleItems: PuzzleWorkSummary[];
|
||||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||||
barkBattleItems?: BarkBattleWorkSummary[];
|
barkBattleItems?: BarkBattleWorkSummary[];
|
||||||
@@ -144,6 +152,7 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
canDeleteBigFish?: boolean;
|
canDeleteBigFish?: boolean;
|
||||||
canDeleteMatch3D?: boolean;
|
canDeleteMatch3D?: boolean;
|
||||||
canDeleteSquareHole?: boolean;
|
canDeleteSquareHole?: boolean;
|
||||||
|
canDeleteJumpHop?: boolean;
|
||||||
canDeletePuzzle?: boolean;
|
canDeletePuzzle?: boolean;
|
||||||
canDeleteBabyObjectMatch?: boolean;
|
canDeleteBabyObjectMatch?: boolean;
|
||||||
canDeleteBarkBattle?: boolean;
|
canDeleteBarkBattle?: boolean;
|
||||||
@@ -157,6 +166,8 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
onDeleteMatch3D?: (item: Match3DWorkSummary) => void;
|
onDeleteMatch3D?: (item: Match3DWorkSummary) => void;
|
||||||
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
|
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
|
||||||
onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void;
|
onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void;
|
||||||
|
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
||||||
|
onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void;
|
||||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||||
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
|
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
|
||||||
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
|
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
|
||||||
@@ -176,6 +187,7 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
bigFishItems,
|
bigFishItems,
|
||||||
match3dItems = [],
|
match3dItems = [],
|
||||||
squareHoleItems = [],
|
squareHoleItems = [],
|
||||||
|
jumpHopItems = [],
|
||||||
puzzleItems,
|
puzzleItems,
|
||||||
babyObjectMatchItems = [],
|
babyObjectMatchItems = [],
|
||||||
barkBattleItems = [],
|
barkBattleItems = [],
|
||||||
@@ -184,6 +196,7 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
canDeleteBigFish = false,
|
canDeleteBigFish = false,
|
||||||
canDeleteMatch3D = false,
|
canDeleteMatch3D = false,
|
||||||
canDeleteSquareHole = false,
|
canDeleteSquareHole = false,
|
||||||
|
canDeleteJumpHop = false,
|
||||||
canDeletePuzzle = false,
|
canDeletePuzzle = false,
|
||||||
canDeleteBabyObjectMatch = false,
|
canDeleteBabyObjectMatch = false,
|
||||||
canDeleteBarkBattle = false,
|
canDeleteBarkBattle = false,
|
||||||
@@ -197,6 +210,8 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
onDeleteMatch3D,
|
onDeleteMatch3D,
|
||||||
onOpenSquareHoleDetail,
|
onOpenSquareHoleDetail,
|
||||||
onDeleteSquareHole,
|
onDeleteSquareHole,
|
||||||
|
onOpenJumpHopDetail,
|
||||||
|
onDeleteJumpHop,
|
||||||
onOpenPuzzleDetail,
|
onOpenPuzzleDetail,
|
||||||
onDeletePuzzle,
|
onDeletePuzzle,
|
||||||
onClaimPuzzlePointIncentive,
|
onClaimPuzzlePointIncentive,
|
||||||
@@ -235,6 +250,12 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
onDelete: onDeleteSquareHole,
|
onDelete: onDeleteSquareHole,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
...jumpHopItems.map((item) =>
|
||||||
|
mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, {
|
||||||
|
onOpen: onOpenJumpHopDetail,
|
||||||
|
onDelete: onDeleteJumpHop,
|
||||||
|
}),
|
||||||
|
),
|
||||||
...puzzleItems.map((item) =>
|
...puzzleItems.map((item) =>
|
||||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
|
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
|
||||||
onOpen: onOpenPuzzleDetail,
|
onOpen: onOpenPuzzleDetail,
|
||||||
@@ -745,6 +766,51 @@ function mapSquareHoleWorkToShelfItem(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapJumpHopWorkToShelfItem(
|
||||||
|
item: JumpHopWorkSummaryResponse,
|
||||||
|
canDelete: boolean,
|
||||||
|
adapter: WorkShelfAdapter<JumpHopWorkSummaryResponse>,
|
||||||
|
): CreationWorkShelfItem {
|
||||||
|
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||||
|
const publicWorkCode =
|
||||||
|
status === 'published' ? buildJumpHopPublicWorkCode(item.profileId) : null;
|
||||||
|
const coverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
|
||||||
|
return {
|
||||||
|
id: item.workId,
|
||||||
|
kind: 'jump-hop',
|
||||||
|
status,
|
||||||
|
title: item.workTitle,
|
||||||
|
summary: item.workDescription,
|
||||||
|
authorDisplayName: resolveAuthorDisplayName(item),
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
coverImageSrc,
|
||||||
|
coverRenderMode: 'image',
|
||||||
|
coverCharacterImageSrcs: [],
|
||||||
|
publicWorkCode,
|
||||||
|
sharePath:
|
||||||
|
publicWorkCode && status === 'published'
|
||||||
|
? buildPublicWorkStagePath('work-detail', publicWorkCode)
|
||||||
|
: null,
|
||||||
|
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
|
||||||
|
canDelete,
|
||||||
|
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||||
|
badges: [
|
||||||
|
buildStatusBadge(status),
|
||||||
|
{ id: 'type', label: '跳一跳', tone: 'neutral' },
|
||||||
|
],
|
||||||
|
metrics:
|
||||||
|
status === 'published'
|
||||||
|
? buildPublishedMetrics({
|
||||||
|
playCount: item.playCount,
|
||||||
|
remixCount: 0,
|
||||||
|
likeCount: 0,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
actions: buildWorkShelfActions(item, adapter),
|
||||||
|
source: { kind: 'jump-hop', item },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function resolveAuthorDisplayName(
|
function resolveAuthorDisplayName(
|
||||||
...sources: Array<unknown>
|
...sources: Array<unknown>
|
||||||
|
|||||||
@@ -177,9 +177,10 @@ import {
|
|||||||
type JumpHopRunResponse,
|
type JumpHopRunResponse,
|
||||||
type JumpHopSessionResponse,
|
type JumpHopSessionResponse,
|
||||||
type JumpHopSessionSnapshotResponse,
|
type JumpHopSessionSnapshotResponse,
|
||||||
type JumpHopWorkProfileResponse,
|
JumpHopWorkProfileResponse,
|
||||||
type JumpHopWorkspaceCreateRequest,
|
JumpHopWorkspaceCreateRequest,
|
||||||
} from '../../services/jump-hop/jumpHopClient';
|
} from '../../services/jump-hop/jumpHopClient';
|
||||||
|
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||||
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
|
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
|
||||||
import {
|
import {
|
||||||
@@ -1853,7 +1854,7 @@ function hasRecoverableGeneratedPuzzleDraft(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) {
|
function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] {
|
||||||
switch (item.source.kind) {
|
switch (item.source.kind) {
|
||||||
case 'rpg':
|
case 'rpg':
|
||||||
return collectDraftNoticeKeys('rpg', [
|
return collectDraftNoticeKeys('rpg', [
|
||||||
@@ -1882,6 +1883,13 @@ function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) {
|
|||||||
item.source.item.profileId,
|
item.source.item.profileId,
|
||||||
item.source.item.sourceSessionId,
|
item.source.item.sourceSessionId,
|
||||||
]);
|
]);
|
||||||
|
case 'jump-hop':
|
||||||
|
return collectDraftNoticeKeys('jump-hop', [
|
||||||
|
item.id,
|
||||||
|
item.source.item.workId,
|
||||||
|
item.source.item.profileId,
|
||||||
|
item.source.item.sourceSessionId,
|
||||||
|
]);
|
||||||
case 'puzzle':
|
case 'puzzle':
|
||||||
return collectDraftNoticeKeys('puzzle', [
|
return collectDraftNoticeKeys('puzzle', [
|
||||||
item.id,
|
item.id,
|
||||||
@@ -1967,6 +1975,39 @@ function buildPendingBigFishWorks(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPendingJumpHopWorks(
|
||||||
|
pending: Record<string, PendingDraftShelfState> | undefined,
|
||||||
|
existingItems: readonly JumpHopWorkSummaryResponse[],
|
||||||
|
): JumpHopWorkSummaryResponse[] {
|
||||||
|
if (!pending) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(pending)
|
||||||
|
.filter(([sessionId]) =>
|
||||||
|
existingItems.every((item) => item.sourceSessionId !== sessionId),
|
||||||
|
)
|
||||||
|
.map(([sessionId, state]) => ({
|
||||||
|
runtimeKind: 'jump-hop',
|
||||||
|
workId: `jump-hop-work-${sessionId}`,
|
||||||
|
profileId: `jump-hop-profile-${sessionId}`,
|
||||||
|
ownerUserId: '',
|
||||||
|
sourceSessionId: sessionId,
|
||||||
|
workTitle: '跳一跳草稿',
|
||||||
|
workDescription: '正在生成跳一跳玩法草稿。',
|
||||||
|
themeTags: [],
|
||||||
|
difficulty: 'standard',
|
||||||
|
stylePreset: 'minimal-blocks',
|
||||||
|
coverImageSrc: null,
|
||||||
|
publicationStatus: 'draft',
|
||||||
|
playCount: 0,
|
||||||
|
updatedAt: state.updatedAt,
|
||||||
|
publishedAt: null,
|
||||||
|
publishReady: false,
|
||||||
|
generationStatus: state.status === 'generating' ? 'generating' : 'ready',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function buildPendingMatch3DWorks(
|
function buildPendingMatch3DWorks(
|
||||||
pending: Record<string, PendingDraftShelfState> | undefined,
|
pending: Record<string, PendingDraftShelfState> | undefined,
|
||||||
existingItems: readonly Match3DWorkSummary[],
|
existingItems: readonly Match3DWorkSummary[],
|
||||||
@@ -2637,6 +2678,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const [jumpHopGalleryEntries, setJumpHopGalleryEntries] = useState<
|
const [jumpHopGalleryEntries, setJumpHopGalleryEntries] = useState<
|
||||||
JumpHopGalleryCardResponse[]
|
JumpHopGalleryCardResponse[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [jumpHopWorks, setJumpHopWorks] = useState<
|
||||||
|
JumpHopWorkSummaryResponse[]
|
||||||
|
>([]);
|
||||||
const [jumpHopRuntimeReturnStage, setJumpHopRuntimeReturnStage] =
|
const [jumpHopRuntimeReturnStage, setJumpHopRuntimeReturnStage] =
|
||||||
useState<JumpHopRuntimeReturnStage>('jump-hop-result');
|
useState<JumpHopRuntimeReturnStage>('jump-hop-result');
|
||||||
const [jumpHopGenerationState, setJumpHopGenerationState] =
|
const [jumpHopGenerationState, setJumpHopGenerationState] =
|
||||||
@@ -2855,6 +2899,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
creationEntryTypes,
|
creationEntryTypes,
|
||||||
'big-fish',
|
'big-fish',
|
||||||
);
|
);
|
||||||
|
const isJumpHopCreationVisible = isPlatformCreationTypeVisible(
|
||||||
|
creationEntryTypes,
|
||||||
|
'jump-hop',
|
||||||
|
);
|
||||||
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
|
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
|
||||||
creationEntryTypes,
|
creationEntryTypes,
|
||||||
'square-hole',
|
'square-hole',
|
||||||
@@ -3304,6 +3352,22 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshJumpHopShelf = useCallback(async () => {
|
||||||
|
if (!isJumpHopCreationVisible) {
|
||||||
|
setJumpHopWorks([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const worksResponse = await jumpHopClient.listWorks();
|
||||||
|
setJumpHopWorks(worksResponse.items);
|
||||||
|
return worksResponse.items;
|
||||||
|
} catch {
|
||||||
|
setJumpHopWorks([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [isJumpHopCreationVisible]);
|
||||||
|
|
||||||
const refreshWoodenFishGallery = useCallback(async () => {
|
const refreshWoodenFishGallery = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const galleryResponse = await woodenFishClient.listGallery();
|
const galleryResponse = await woodenFishClient.listGallery();
|
||||||
@@ -3513,6 +3577,22 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
selectionStage,
|
selectionStage,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!platformBootstrap.canReadProtectedData) {
|
||||||
|
setJumpHopWorks([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platformBootstrap.platformTab === 'create' || selectionStage === 'platform') {
|
||||||
|
void refreshJumpHopShelf();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
platformBootstrap.canReadProtectedData,
|
||||||
|
platformBootstrap.platformTab,
|
||||||
|
refreshJumpHopShelf,
|
||||||
|
selectionStage,
|
||||||
|
]);
|
||||||
|
|
||||||
const sessionController = useRpgCreationSessionController({
|
const sessionController = useRpgCreationSessionController({
|
||||||
userId: authUi?.user?.id,
|
userId: authUi?.user?.id,
|
||||||
openLoginModal: authUi?.openLoginModal,
|
openLoginModal: authUi?.openLoginModal,
|
||||||
@@ -3860,6 +3940,16 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
],
|
],
|
||||||
[bigFishWorks, pendingDraftShelfItems],
|
[bigFishWorks, pendingDraftShelfItems],
|
||||||
);
|
);
|
||||||
|
const jumpHopShelfItems = useMemo(
|
||||||
|
() => [
|
||||||
|
...buildPendingJumpHopWorks(
|
||||||
|
pendingDraftShelfItems['jump-hop'],
|
||||||
|
jumpHopWorks,
|
||||||
|
),
|
||||||
|
...jumpHopWorks,
|
||||||
|
],
|
||||||
|
[jumpHopWorks, pendingDraftShelfItems],
|
||||||
|
);
|
||||||
const match3dShelfItems = useMemo(
|
const match3dShelfItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks),
|
...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks),
|
||||||
@@ -3935,6 +4025,13 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
...bigFishShelfItems.flatMap((item) =>
|
...bigFishShelfItems.flatMap((item) =>
|
||||||
collectDraftNoticeKeys('big-fish', [item.workId, item.sourceSessionId]),
|
collectDraftNoticeKeys('big-fish', [item.workId, item.sourceSessionId]),
|
||||||
),
|
),
|
||||||
|
...jumpHopShelfItems.flatMap((item) =>
|
||||||
|
collectDraftNoticeKeys('jump-hop', [
|
||||||
|
item.workId,
|
||||||
|
item.profileId,
|
||||||
|
item.sourceSessionId,
|
||||||
|
]),
|
||||||
|
),
|
||||||
...match3dShelfItems.flatMap((item) =>
|
...match3dShelfItems.flatMap((item) =>
|
||||||
collectDraftNoticeKeys('match3d', [
|
collectDraftNoticeKeys('match3d', [
|
||||||
item.workId,
|
item.workId,
|
||||||
@@ -3977,6 +4074,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
babyObjectMatchDrafts,
|
babyObjectMatchDrafts,
|
||||||
barkBattleShelfItems,
|
barkBattleShelfItems,
|
||||||
bigFishShelfItems,
|
bigFishShelfItems,
|
||||||
|
jumpHopShelfItems,
|
||||||
creationHubItems,
|
creationHubItems,
|
||||||
isSquareHoleCreationVisible,
|
isSquareHoleCreationVisible,
|
||||||
match3dShelfItems,
|
match3dShelfItems,
|
||||||
@@ -7312,6 +7410,22 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setJumpHopSession(response.session);
|
setJumpHopSession(response.session);
|
||||||
setJumpHopWork(response.work ?? null);
|
setJumpHopWork(response.work ?? null);
|
||||||
setJumpHopGenerationState(readyState);
|
setJumpHopGenerationState(readyState);
|
||||||
|
if (response.work) {
|
||||||
|
setJumpHopWorks((current) =>
|
||||||
|
[response.work!.summary, ...current.filter((item) => item.workId !== response.work!.summary.workId)],
|
||||||
|
);
|
||||||
|
markPendingDraftReady('jump-hop', created.session.sessionId, false);
|
||||||
|
markDraftReady(
|
||||||
|
'jump-hop',
|
||||||
|
[
|
||||||
|
created.session.sessionId,
|
||||||
|
response.work.summary.workId,
|
||||||
|
response.work.summary.profileId,
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
void refreshJumpHopShelf().catch(() => undefined);
|
||||||
|
}
|
||||||
setSelectionStage('jump-hop-result');
|
setSelectionStage('jump-hop-result');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = resolveRpgCreationErrorMessage(
|
const errorMessage = resolveRpgCreationErrorMessage(
|
||||||
@@ -7426,6 +7540,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
try {
|
try {
|
||||||
const response = await jumpHopClient.publishWork(profileId);
|
const response = await jumpHopClient.publishWork(profileId);
|
||||||
setJumpHopWork(response.item);
|
setJumpHopWork(response.item);
|
||||||
|
setJumpHopWorks((current) =>
|
||||||
|
[response.item.summary, ...current.filter((item) => item.workId !== response.item.summary.workId)],
|
||||||
|
);
|
||||||
|
void refreshJumpHopShelf().catch(() => undefined);
|
||||||
openPublishShareModal({
|
openPublishShareModal({
|
||||||
title: response.item.summary.workTitle || '跳一跳',
|
title: response.item.summary.workTitle || '跳一跳',
|
||||||
publicWorkCode: buildJumpHopPublicWorkCode(
|
publicWorkCode: buildJumpHopPublicWorkCode(
|
||||||
@@ -10121,6 +10239,43 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
[openPublicWorkDetail, setJumpHopError, setSelectionStage],
|
[openPublicWorkDetail, setJumpHopError, setSelectionStage],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openJumpHopDraft = useCallback(
|
||||||
|
async (item: JumpHopWorkSummaryResponse) => {
|
||||||
|
markDraftNoticeSeen(
|
||||||
|
collectDraftNoticeKeys('jump-hop', [
|
||||||
|
item.workId,
|
||||||
|
item.profileId,
|
||||||
|
item.sourceSessionId,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (item.publicationStatus === 'published') {
|
||||||
|
void openJumpHopPublicWorkDetail(item.profileId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setJumpHopError(null);
|
||||||
|
setPublicWorkDetailError(null);
|
||||||
|
setIsJumpHopBusy(true);
|
||||||
|
try {
|
||||||
|
const detail = await jumpHopClient.getWorkDetail(item.profileId);
|
||||||
|
setJumpHopSession(null);
|
||||||
|
setJumpHopRun(null);
|
||||||
|
setJumpHopWork(detail.item);
|
||||||
|
setJumpHopRuntimeReturnStage('jump-hop-result');
|
||||||
|
enterCreateTab();
|
||||||
|
setSelectionStage('jump-hop-result');
|
||||||
|
} catch (error) {
|
||||||
|
setJumpHopError(
|
||||||
|
resolveRpgCreationErrorMessage(error, '读取跳一跳草稿失败。'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsJumpHopBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enterCreateTab, markDraftNoticeSeen, openPublicWorkDetail, setSelectionStage],
|
||||||
|
);
|
||||||
|
|
||||||
const openWoodenFishPublicWorkDetail = useCallback(
|
const openWoodenFishPublicWorkDetail = useCallback(
|
||||||
async (profileId: string) => {
|
async (profileId: string) => {
|
||||||
setIsPublicWorkDetailBusy(true);
|
setIsPublicWorkDetailBusy(true);
|
||||||
@@ -12842,6 +12997,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
deletingWorkId={deletingCreationWorkId}
|
deletingWorkId={deletingCreationWorkId}
|
||||||
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
|
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
|
||||||
bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []}
|
bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []}
|
||||||
|
jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []}
|
||||||
onOpenBigFishDetail={
|
onOpenBigFishDetail={
|
||||||
isBigFishCreationVisible
|
isBigFishCreationVisible
|
||||||
? (item) => {
|
? (item) => {
|
||||||
@@ -12851,6 +13007,15 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onOpenJumpHopDetail={
|
||||||
|
isJumpHopCreationVisible
|
||||||
|
? (item) => {
|
||||||
|
runProtectedAction(() => {
|
||||||
|
void openJumpHopDraft(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDeleteBigFish={
|
onDeleteBigFish={
|
||||||
isBigFishCreationVisible
|
isBigFishCreationVisible
|
||||||
? (item) => {
|
? (item) => {
|
||||||
@@ -12858,6 +13023,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
onDeleteJumpHop={null}
|
||||||
match3dItems={match3dShelfItems}
|
match3dItems={match3dShelfItems}
|
||||||
onOpenMatch3DDetail={(item) => {
|
onOpenMatch3DDetail={(item) => {
|
||||||
runProtectedAction(() => {
|
runProtectedAction(() => {
|
||||||
|
|||||||
@@ -551,9 +551,9 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
|
|||||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||||
seedText: 'first-level.png',
|
seedText: 'first-level.png',
|
||||||
pictureDescription: 'first-level.png',
|
pictureDescription: 'first-level.png',
|
||||||
referenceImageSrc: '/generated-puzzle-assets/reference/first-level.png',
|
referenceImageSrc: 'data:image/png;base64,uploaded-square',
|
||||||
referenceImageSrcs: [],
|
referenceImageSrcs: [],
|
||||||
referenceImageAssetObjectId: 'asset-reference-first-level.png',
|
referenceImageAssetObjectId: null,
|
||||||
referenceImageAssetObjectIds: [],
|
referenceImageAssetObjectIds: [],
|
||||||
imageModel: 'gpt-image-2',
|
imageModel: 'gpt-image-2',
|
||||||
aiRedraw: false,
|
aiRedraw: false,
|
||||||
@@ -616,22 +616,10 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => {
|
test('puzzle workspace submits uploaded reference image as data URL when AI redraw is on', async () => {
|
||||||
const onCreateFromForm = vi.fn();
|
const onCreateFromForm = vi.fn();
|
||||||
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
|
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
|
||||||
stubReferenceImageUpload(uploadedDataUrl);
|
stubReferenceImageUpload(uploadedDataUrl);
|
||||||
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockResolvedValue({
|
|
||||||
assetObjectId: 'asset-reference-main-1',
|
|
||||||
assetKind: 'puzzle_cover_image',
|
|
||||||
objectKey: 'generated-puzzle-assets/reference/main-1.png',
|
|
||||||
imageSrc: '/generated-puzzle-assets/reference/main-1.png',
|
|
||||||
ownerUserId: 'user-1',
|
|
||||||
ownerLabel: '账号 user-1',
|
|
||||||
profileId: null,
|
|
||||||
entityId: null,
|
|
||||||
createdAt: '1713686400.000000Z',
|
|
||||||
updatedAt: '1713686400.000000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<PuzzleAgentWorkspace
|
<PuzzleAgentWorkspace
|
||||||
@@ -651,9 +639,7 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByAltText('拼图图片')).toBeTruthy();
|
expect(screen.getByAltText('拼图图片')).toBeTruthy();
|
||||||
});
|
});
|
||||||
expect(puzzleAssetClient.uploadReferenceImage).toHaveBeenCalledWith({
|
expect(puzzleAssetClient.uploadReferenceImage).not.toHaveBeenCalled();
|
||||||
file: expect.any(File),
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), {
|
fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), {
|
||||||
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
|
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
|
||||||
});
|
});
|
||||||
@@ -663,9 +649,9 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
|
|||||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||||
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
|
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
|
||||||
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
|
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
|
||||||
referenceImageSrc: null,
|
referenceImageSrc: 'data:image/png;base64,uploaded-square',
|
||||||
referenceImageSrcs: [],
|
referenceImageSrcs: [],
|
||||||
referenceImageAssetObjectId: 'asset-reference-main-1',
|
referenceImageAssetObjectId: null,
|
||||||
referenceImageAssetObjectIds: [],
|
referenceImageAssetObjectIds: [],
|
||||||
imageModel: 'gpt-image-2',
|
imageModel: 'gpt-image-2',
|
||||||
aiRedraw: true,
|
aiRedraw: true,
|
||||||
@@ -754,12 +740,12 @@ test('puzzle workspace uploads prompt references as asset object ids', async ()
|
|||||||
seedText: '一只猫在雨夜灯牌下回头。',
|
seedText: '一只猫在雨夜灯牌下回头。',
|
||||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||||
referenceImageSrc: null,
|
referenceImageSrc: null,
|
||||||
referenceImageSrcs: [],
|
referenceImageSrcs: [
|
||||||
referenceImageAssetObjectId: null,
|
'data:image/png;base64,reference-1',
|
||||||
referenceImageAssetObjectIds: [
|
'data:image/png;base64,reference-2',
|
||||||
'asset-reference-prompt-1',
|
|
||||||
'asset-reference-prompt-2',
|
|
||||||
],
|
],
|
||||||
|
referenceImageAssetObjectId: null,
|
||||||
|
referenceImageAssetObjectIds: [],
|
||||||
imageModel: 'gpt-image-2',
|
imageModel: 'gpt-image-2',
|
||||||
aiRedraw: true,
|
aiRedraw: true,
|
||||||
});
|
});
|
||||||
@@ -842,15 +828,15 @@ test('puzzle workspace uploads prompt reference images from the description box'
|
|||||||
seedText: '一只猫在雨夜灯牌下回头。',
|
seedText: '一只猫在雨夜灯牌下回头。',
|
||||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||||
referenceImageSrc: null,
|
referenceImageSrc: null,
|
||||||
referenceImageSrcs: [],
|
referenceImageSrcs: [
|
||||||
referenceImageAssetObjectId: null,
|
'data:image/png;base64,reference-1',
|
||||||
referenceImageAssetObjectIds: [
|
'data:image/png;base64,reference-2',
|
||||||
'asset-reference-reference-1.png',
|
'data:image/png;base64,reference-3',
|
||||||
'asset-reference-reference-2.png',
|
'data:image/png;base64,reference-4',
|
||||||
'asset-reference-reference-3.png',
|
'data:image/png;base64,reference-5',
|
||||||
'asset-reference-reference-4.png',
|
|
||||||
'asset-reference-reference-5.png',
|
|
||||||
],
|
],
|
||||||
|
referenceImageAssetObjectId: null,
|
||||||
|
referenceImageAssetObjectIds: [],
|
||||||
imageModel: 'gpt-image-2',
|
imageModel: 'gpt-image-2',
|
||||||
aiRedraw: true,
|
aiRedraw: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works
|
|||||||
import {
|
import {
|
||||||
cropPuzzleReferenceImageDataUrl,
|
cropPuzzleReferenceImageDataUrl,
|
||||||
isPuzzleReferenceImageSquare,
|
isPuzzleReferenceImageSquare,
|
||||||
puzzleReferenceImageDataUrlToFile,
|
|
||||||
readPuzzleReferenceImageAsDataUrl,
|
readPuzzleReferenceImageAsDataUrl,
|
||||||
readPuzzleReferenceImageForUpload,
|
readPuzzleReferenceImageForUpload,
|
||||||
} from '../../services/puzzleReferenceImage';
|
} from '../../services/puzzleReferenceImage';
|
||||||
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
|
|
||||||
import {
|
import {
|
||||||
CreativeImageInputPanel,
|
CreativeImageInputPanel,
|
||||||
type CreativeImageInputReferenceImage,
|
type CreativeImageInputReferenceImage,
|
||||||
@@ -409,11 +407,10 @@ export function PuzzleAgentWorkspace({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
|
|
||||||
setFormState((current) => ({
|
setFormState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
referenceImageSrc: asset.imageSrc || uploadImage.dataUrl,
|
referenceImageSrc: uploadImage.dataUrl,
|
||||||
referenceImageAssetObjectId: asset.assetObjectId,
|
referenceImageAssetObjectId: '',
|
||||||
referenceImageLabel: file.name.trim() || '本地拼图图片',
|
referenceImageLabel: file.name.trim() || '本地拼图图片',
|
||||||
}));
|
}));
|
||||||
setReferenceImageError(null);
|
setReferenceImageError(null);
|
||||||
@@ -441,18 +438,12 @@ export function PuzzleAgentWorkspace({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const images = await Promise.all(
|
const images = await Promise.all(
|
||||||
files.slice(0, remainingSlots).map(async (file, index) => {
|
files.slice(0, remainingSlots).map(async (file, index) => ({
|
||||||
const [imageSrc, asset] = await Promise.all([
|
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
|
||||||
readPuzzleReferenceImageAsDataUrl(file),
|
label: file.name.trim() || `参考图 ${index + 1}`,
|
||||||
puzzleAssetClient.uploadReferenceImage({ file }),
|
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
|
||||||
]);
|
assetObjectId: null,
|
||||||
return {
|
})),
|
||||||
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
|
|
||||||
label: file.name.trim() || `参考图 ${index + 1}`,
|
|
||||||
imageSrc: asset.imageSrc || imageSrc,
|
|
||||||
assetObjectId: asset.assetObjectId,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
setFormState((current) => ({
|
setFormState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -515,15 +506,10 @@ export function PuzzleAgentWorkspace({
|
|||||||
cropY: currentCropState.cropRect.y,
|
cropY: currentCropState.cropRect.y,
|
||||||
cropSize: currentCropState.cropRect.size,
|
cropSize: currentCropState.cropRect.size,
|
||||||
});
|
});
|
||||||
const file = puzzleReferenceImageDataUrlToFile(
|
|
||||||
dataUrl,
|
|
||||||
currentCropState.fileName,
|
|
||||||
);
|
|
||||||
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
|
|
||||||
setFormState((current) => ({
|
setFormState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
referenceImageSrc: asset.imageSrc || dataUrl,
|
referenceImageSrc: dataUrl,
|
||||||
referenceImageAssetObjectId: asset.assetObjectId,
|
referenceImageAssetObjectId: '',
|
||||||
referenceImageLabel: currentCropState.label,
|
referenceImageLabel: currentCropState.label,
|
||||||
}));
|
}));
|
||||||
setCropState(null);
|
setCropState(null);
|
||||||
@@ -651,6 +637,7 @@ export function PuzzleAgentWorkspace({
|
|||||||
aiRedraw={formState.aiRedraw}
|
aiRedraw={formState.aiRedraw}
|
||||||
promptReferenceImages={formState.referenceImageSrcs}
|
promptReferenceImages={formState.referenceImageSrcs}
|
||||||
promptReferenceLimit={PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT}
|
promptReferenceLimit={PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT}
|
||||||
|
imageLimitHint="图片≤6MB"
|
||||||
imageModelPicker={
|
imageModelPicker={
|
||||||
<PuzzleImageModelPicker
|
<PuzzleImageModelPicker
|
||||||
value={formState.imageModel}
|
value={formState.imageModel}
|
||||||
|
|||||||
@@ -840,6 +840,7 @@ function PuzzleLevelDetailDialog({
|
|||||||
aiRedraw={aiRedraw}
|
aiRedraw={aiRedraw}
|
||||||
promptReferenceImages={promptReferenceImages}
|
promptReferenceImages={promptReferenceImages}
|
||||||
promptReferenceLimit={PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT}
|
promptReferenceLimit={PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT}
|
||||||
|
imageLimitHint="图片≤6MB"
|
||||||
imageModelPicker={
|
imageModelPicker={
|
||||||
<PuzzleImageModelPicker
|
<PuzzleImageModelPicker
|
||||||
value={imageModel}
|
value={imageModel}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
JumpHopWorkDetailResponse,
|
JumpHopWorkDetailResponse,
|
||||||
JumpHopWorkMutationResponse,
|
JumpHopWorkMutationResponse,
|
||||||
JumpHopWorkProfileResponse,
|
JumpHopWorkProfileResponse,
|
||||||
|
JumpHopWorksResponse,
|
||||||
JumpHopWorkspaceCreateRequest,
|
JumpHopWorkspaceCreateRequest,
|
||||||
JumpHopWorkSummaryResponse,
|
JumpHopWorkSummaryResponse,
|
||||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
@@ -41,6 +42,7 @@ export type {
|
|||||||
JumpHopWorkDetailResponse,
|
JumpHopWorkDetailResponse,
|
||||||
JumpHopWorkMutationResponse,
|
JumpHopWorkMutationResponse,
|
||||||
JumpHopWorkProfileResponse,
|
JumpHopWorkProfileResponse,
|
||||||
|
JumpHopWorksResponse,
|
||||||
JumpHopWorkspaceCreateRequest,
|
JumpHopWorkspaceCreateRequest,
|
||||||
};
|
};
|
||||||
export type CreateJumpHopSessionRequest = {
|
export type CreateJumpHopSessionRequest = {
|
||||||
@@ -199,6 +201,17 @@ export async function getJumpHopGalleryDetail(publicWorkCode: string) {
|
|||||||
return normalizeJumpHopWorkDetailResponse(response);
|
return normalizeJumpHopWorkDetailResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listJumpHopWorks() {
|
||||||
|
return requestJson<JumpHopWorksResponse>(
|
||||||
|
JUMP_HOP_WORKS_API_BASE,
|
||||||
|
{ method: 'GET' },
|
||||||
|
'读取跳一跳作品列表失败',
|
||||||
|
{
|
||||||
|
retry: JUMP_HOP_RUNTIME_READ_RETRY,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function publishJumpHopWork(profileId: string) {
|
export async function publishJumpHopWork(profileId: string) {
|
||||||
const response = await requestJson<JumpHopWorkMutationResponse>(
|
const response = await requestJson<JumpHopWorkMutationResponse>(
|
||||||
`${JUMP_HOP_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
|
`${JUMP_HOP_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
|
||||||
@@ -267,6 +280,7 @@ export const jumpHopClient = {
|
|||||||
getGalleryDetail: getJumpHopGalleryDetail,
|
getGalleryDetail: getJumpHopGalleryDetail,
|
||||||
getWorkDetail: getJumpHopWorkDetail,
|
getWorkDetail: getJumpHopWorkDetail,
|
||||||
listGallery: listJumpHopGallery,
|
listGallery: listJumpHopGallery,
|
||||||
|
listWorks: listJumpHopWorks,
|
||||||
publishWork: publishJumpHopWork,
|
publishWork: publishJumpHopWork,
|
||||||
restartRun: restartJumpHopRuntimeRun,
|
restartRun: restartJumpHopRuntimeRun,
|
||||||
startRun: startJumpHopRuntimeRun,
|
startRun: startJumpHopRuntimeRun,
|
||||||
|
|||||||
24
src/services/puzzle-works/puzzleAssetClient.test.ts
Normal file
24
src/services/puzzle-works/puzzleAssetClient.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
|
||||||
|
validatePuzzleReferenceImageFile,
|
||||||
|
} from './puzzleAssetClient';
|
||||||
|
|
||||||
|
describe('puzzle reference image upload validation', () => {
|
||||||
|
test('limits uploads to 6MB', () => {
|
||||||
|
expect(PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES).toBe(6 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects files that exceed the upload limit with a precise message', () => {
|
||||||
|
const file = new File([
|
||||||
|
'x'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES + 1),
|
||||||
|
], 'too-large.png', { type: 'image/png' });
|
||||||
|
|
||||||
|
expect(() => validatePuzzleReferenceImageFile(file)).toThrow(
|
||||||
|
'参考图过大,请压缩后再上传(当前 6.0MB,最多 6MB)。',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
|
import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
|
||||||
import { requestJson } from '../apiClient';
|
import { requestJson } from '../apiClient';
|
||||||
|
import {
|
||||||
|
PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
|
||||||
|
validatePuzzleReferenceImageFile,
|
||||||
|
} from '../puzzleReferenceImage';
|
||||||
|
|
||||||
export type PuzzleHistoryAsset = {
|
export type PuzzleHistoryAsset = {
|
||||||
assetObjectId: string;
|
assetObjectId: string;
|
||||||
@@ -40,8 +44,6 @@ type ConfirmAssetObjectResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
|
||||||
|
|
||||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||||
jpeg: 'image/jpeg',
|
jpeg: 'image/jpeg',
|
||||||
jpg: 'image/jpeg',
|
jpg: 'image/jpeg',
|
||||||
@@ -58,14 +60,9 @@ function resolvePuzzleImageContentType(file: File) {
|
|||||||
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
|
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
function validatePuzzleReferenceImageFile(file: File) {
|
function validatePuzzleReferenceImageUploadFile(file: File) {
|
||||||
const contentType = resolvePuzzleImageContentType(file);
|
const contentType = resolvePuzzleImageContentType(file);
|
||||||
if (file.size <= 0) {
|
validatePuzzleReferenceImageFile(file);
|
||||||
throw new Error('参考图文件为空,请重新选择。');
|
|
||||||
}
|
|
||||||
if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) {
|
|
||||||
throw new Error('参考图过大,请压缩后再上传。');
|
|
||||||
}
|
|
||||||
if (!contentType.startsWith('image/')) {
|
if (!contentType.startsWith('image/')) {
|
||||||
throw new Error('参考图必须是图片文件。');
|
throw new Error('参考图必须是图片文件。');
|
||||||
}
|
}
|
||||||
@@ -96,7 +93,7 @@ async function postDirectUploadFile(
|
|||||||
export async function uploadPuzzleReferenceImage(payload: {
|
export async function uploadPuzzleReferenceImage(payload: {
|
||||||
file: File;
|
file: File;
|
||||||
}): Promise<PuzzleReferenceAsset> {
|
}): Promise<PuzzleReferenceAsset> {
|
||||||
validatePuzzleReferenceImageFile(payload.file);
|
validatePuzzleReferenceImageUploadFile(payload.file);
|
||||||
const contentType = resolvePuzzleImageContentType(payload.file);
|
const contentType = resolvePuzzleImageContentType(payload.file);
|
||||||
const uploadedAt = Date.now();
|
const uploadedAt = Date.now();
|
||||||
const ticket = await requestJson<DirectUploadTicketResponse>(
|
const ticket = await requestJson<DirectUploadTicketResponse>(
|
||||||
@@ -157,7 +154,12 @@ export async function uploadPuzzleReferenceImage(payload: {
|
|||||||
|
|
||||||
export const puzzleReferenceAssetTestUtils = {
|
export const puzzleReferenceAssetTestUtils = {
|
||||||
maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
|
maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
|
||||||
validateFile: validatePuzzleReferenceImageFile,
|
validateFile: validatePuzzleReferenceImageUploadFile,
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
|
||||||
|
validatePuzzleReferenceImageUploadFile as validatePuzzleReferenceImageFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ describe('readPuzzleReferenceImageAsDataUrl', () => {
|
|||||||
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
|
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
|
||||||
|
|
||||||
expect(dataUrl).toBe(`data:image/jpeg;base64,${'C'.repeat(1000)}`);
|
expect(dataUrl).toBe(`data:image/jpeg;base64,${'C'.repeat(1000)}`);
|
||||||
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1536, 1152);
|
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1024, 768);
|
||||||
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.84);
|
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.84);
|
||||||
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.76);
|
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.76);
|
||||||
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.68);
|
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.68);
|
||||||
@@ -114,7 +114,7 @@ describe('readPuzzleReferenceImageAsDataUrl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(readPuzzleReferenceImageAsDataUrl(file)).rejects.toThrow(
|
await expect(readPuzzleReferenceImageAsDataUrl(file)).rejects.toThrow(
|
||||||
'参考图过大,请换一张尺寸更小的图片。',
|
'参考图过大,请压缩后再上传(当前 10.0MB,最多 6MB)。',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1024;
|
const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1024;
|
||||||
const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024;
|
const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024;
|
||||||
|
export const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 6 * 1024 * 1024;
|
||||||
export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
|
export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
|
||||||
const PUZZLE_REFERENCE_IMAGE_SQUARE_TOLERANCE = 1;
|
const PUZZLE_REFERENCE_IMAGE_SQUARE_TOLERANCE = 1;
|
||||||
|
|
||||||
|
export function formatPuzzleReferenceImageUploadBytes(bytes: number) {
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPuzzleReferenceImageTooLargeMessage(actualBytes: number) {
|
||||||
|
return `参考图过大,请压缩后再上传(当前 ${formatPuzzleReferenceImageUploadBytes(actualBytes)},最多 6MB)。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePuzzleReferenceImageFile(file: File) {
|
||||||
|
if (file.size <= 0) {
|
||||||
|
throw new Error('参考图文件为空,请重新选择。');
|
||||||
|
}
|
||||||
|
if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) {
|
||||||
|
throw new Error(buildPuzzleReferenceImageTooLargeMessage(file.size));
|
||||||
|
}
|
||||||
|
if (file.type.trim() && !file.type.trim().startsWith('image/')) {
|
||||||
|
throw new Error('参考图必须是图片文件。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type PuzzleReferenceImageSize = {
|
type PuzzleReferenceImageSize = {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -36,7 +57,7 @@ function readFileAsDataUrl(file: File) {
|
|||||||
|
|
||||||
function ensureReferenceImageWithinLimit(dataUrl: string) {
|
function ensureReferenceImageWithinLimit(dataUrl: string) {
|
||||||
if (dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
|
if (dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
|
||||||
throw new Error('参考图过大,请换一张尺寸更小的图片。');
|
throw new Error(buildPuzzleReferenceImageTooLargeMessage(dataUrl.length));
|
||||||
}
|
}
|
||||||
return dataUrl;
|
return dataUrl;
|
||||||
}
|
}
|
||||||
@@ -130,6 +151,7 @@ async function compressReferenceImageDataUrl(file: File, dataUrl: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function readPuzzleReferenceImageAsDataUrl(file: File) {
|
export async function readPuzzleReferenceImageAsDataUrl(file: File) {
|
||||||
|
validatePuzzleReferenceImageFile(file);
|
||||||
const dataUrl = await readFileAsDataUrl(file);
|
const dataUrl = await readFileAsDataUrl(file);
|
||||||
try {
|
try {
|
||||||
const compressedDataUrl = await compressReferenceImageDataUrl(
|
const compressedDataUrl = await compressReferenceImageDataUrl(
|
||||||
@@ -150,6 +172,7 @@ export async function readPuzzleReferenceImageAsDataUrl(file: File) {
|
|||||||
export async function readPuzzleReferenceImageForUpload(
|
export async function readPuzzleReferenceImageForUpload(
|
||||||
file: File,
|
file: File,
|
||||||
): Promise<PuzzleReferenceImageReadResult> {
|
): Promise<PuzzleReferenceImageReadResult> {
|
||||||
|
validatePuzzleReferenceImageFile(file);
|
||||||
const dataUrl = await readFileAsDataUrl(file);
|
const dataUrl = await readFileAsDataUrl(file);
|
||||||
const image = await loadReferenceImage(dataUrl);
|
const image = await loadReferenceImage(dataUrl);
|
||||||
const size = resolveReferenceImageNaturalSize(image);
|
const size = resolveReferenceImageNaturalSize(image);
|
||||||
|
|||||||
Reference in New Issue
Block a user