diff --git a/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md b/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md index 76cb5a01..eb9ec69d 100644 --- a/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md +++ b/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md @@ -73,13 +73,13 @@ - [x] 迁移动作模板查询(Stage 1 已接通 Rust 内置模板列表兼容接口) - [x] 迁移视频导入(Stage 1 已接通 Data URL 视频导入到 OSS 草稿区,不再写本地 `public/`) - [x] 迁移工作流缓存(Stage 1 已接通 Rust `GET/POST character-workflow-cache` 到 OSS JSON 草稿对象,不再写本地 `public/`) -- [x] 迁移场景图生成(Stage 1 已由 custom world `scene-image` 兼容路由写入 OSS 并确认/绑定;Stage 2 还需补真实 DashScope 图片生成,替换当前 Rust SVG 占位图) -- [x] 迁移封面图上传(Stage 1 已由 custom world `cover-image / cover-upload` 兼容路由写入 OSS 并确认/绑定;Stage 2 还需补 `cropRect + 16:9 + WebP 压缩`) +- [x] 迁移场景图生成(已完成 Stage 2:custom world `scene-image` 走真实 DashScope 图片生成,并继续写入 `OSS + asset_object + asset_entity_binding`) +- [x] 迁移封面图上传(已完成 Stage 2:custom world `cover-image / cover-upload` 已补齐真实 DashScope 生成与 `cropRect + 16:9 + WebP 压缩`) - [x] 首批收口 custom world `scene-image / cover-image / cover-upload` 到正式 `OSS + asset_object + asset_entity_binding` 主链(保持旧 `/generated-*` 返回 contract,不再写仓库 `public/`) 补充说明: -1. 本次收口只解决 custom world 兼容图片入口的正式资产真相链,不代表 DashScope 图片生成、任务状态、封面裁剪压缩能力已全量迁完。 +1. custom world 兼容图片入口现已完成 Stage 1 + Stage 2:正式资产真相链、真实 DashScope 图片生成,以及封面上传裁剪压缩都已迁完。 2. 详细边界见: - [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md) 3. 角色动作模板与视频导入第一批已新增独立设计文档,当前只迁移: @@ -93,10 +93,10 @@ 5. `2026-04-22` 复核确认:旧独立 `qwen-sprite-tool + qwenSpriteRoutes.ts` 已在 `2026-04-21` 清理,不再作为本轮现役迁移主链;当前仍保留的 `Qwen` 相关内容仅包括: - 角色资产 prompt 层对 `packages/shared/src/prompts/qwenSprite.ts` 的复用 - 历史资源前缀 `/generated-qwen-sprites/*` 的读取兼容 -6. custom world 图片链已进入 Stage 2: - - `scene-image / cover-image` 需要把 Rust SVG 占位生成替换为真实 DashScope 图片生成 - - `cover-upload` 需要补回 Node 旧链路中的 `cropRect + 16:9 + WebP 压缩` - - 详细口径见 [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md) +6. custom world 图片链 Stage 2 已完成: + - `scene-image / cover-image` 已替换为真实 DashScope 图片生成 + - `cover-upload` 已补回 Node 旧链路中的 `cropRect + 16:9 + WebP 压缩` + - 详细口径与验证结果见 [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md) ## 5. 路径兼容 @@ -149,5 +149,5 @@ - `generate` 直接写入 OSS `generated-character-drafts/*`。 - `jobs/:taskId` 从 `AiTaskService` 派生旧任务状态 contract。 - `publish` 会把动作帧与总 manifest 写入 OSS `generated-animations/*`,并确认 `asset_object + asset_entity_binding`。 -7. custom world 场景图、封面图、封面上传已在 `M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md` 范围内接入正式 `OSS + asset_object + asset_entity_binding` 主链。 +7. custom world 场景图、封面图、封面上传已在 `M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md` + `M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md` 范围内完成正式 `OSS + asset_object + asset_entity_binding` 主链、真实 DashScope 图片生成和封面上传裁剪压缩。 8. `content_hash/version`、`asset_job`、`asset_manifest` 与强业务资产表当前已冻结 Stage 1 边界,不再作为 M6 第一批工程阻塞项;后续若要做内容去重、manifest 查询、审核/回滚或 sprite sheet 强结构化,再进入独立阶段。 diff --git a/docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md b/docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md index 22fbbf8b..6594ee30 100644 --- a/docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md +++ b/docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md @@ -19,6 +19,31 @@ 本批目标就是把这两段缺失能力补齐,同时继续保持 `Stage 1` 已冻结的 OSS 真相链。 +## 1.1 当前落地结果 + +`2026-04-22` 已按本文口径完成 Rust `api-server` Stage 2 落地: + +1. `POST /api/custom-world/scene-image` 已切到真实 DashScope 图片生成 +2. `POST /api/custom-world/cover-image` 已切到真实 DashScope 图片生成 +3. `POST /api/custom-world/cover-upload` 已补齐 `cropRect + 16:9 + 1600x900 + WebP + 1.5 MB` +4. 三条链路继续统一写入 `OSS + asset_object + asset_entity_binding` +5. `/generated-custom-world-scenes/*` 与 `/generated-custom-world-covers/*` 旧读取路径兼容口径保持不变 + +本次同时补齐的兼容细节: + +1. `scene-image` 新增兼容读取 `negativePrompt / referenceImageSrc / userPrompt / profile / landmark` +2. `cover-image` 新增兼容读取 `referenceImageSrc / characterRoleIds` +3. `cover-upload` 新增兼容读取 `cropRect` +4. 参考图输入在 Rust 端兼容两种来源: + - `data:image/*;base64,...` + - 现有 `/generated-*` 旧路径,通过 OSS 短签名回读后转为 Data URL + +本批验证结果: + +1. `cargo check -p api-server` 通过 +2. `cargo test -p api-server custom_world_ai` 通过 +3. `npm run check:encoding` 通过 + ## 2. 本批范围 ### 2.1 要完成的内容 diff --git a/docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md b/docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md index faf61b64..c0dd2aaa 100644 --- a/docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md +++ b/docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md @@ -3,6 +3,8 @@ > 该文档由 `server-node/src/manifest/backendCapabilityManifest.ts` 自动生成。 > 生成命令:`npm run server-node:manifest:backend` > 生成时间:`2026-04-20T14:26:38.663Z` +> +> 过期说明:该索引生成于 `2026-04-20`,其中 `createQwenSpriteRoutes` 与 `/api/assets/qwen-sprite/*` 相关描述已在 `2026-04-21` 后失效。当前 Node 现役资产挂载面仅保留 `createCharacterAssetRoutes`;`Qwen` 仅剩 prompt 模板复用与 `/generated-qwen-sprites/*` 历史路径兼容,不再存在独立路由主链。 ## 总览 diff --git a/docs/technical/README.md b/docs/technical/README.md index 618ef962..75614ef1 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -48,6 +48,7 @@ - [SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md):补齐 `M5` Stage 5 遗漏的 owner-only `GET /api/runtime/custom-world-library/:profileId` 设计,冻结单条 profile detail 的 SpacetimeDB procedure、client facade、404 语义与 Axum 路由扩展方式。 - [SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md):冻结 `M5` 剩余主链的 works、card detail、publish gate、supportedActions、action registry 与 AI/OSS 兼容路由边界,作为 Stage 9 到收口阶段的统一落地依据。 - [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md):冻结 `M6` 第一批 custom world 场景图、封面图、封面上传从本地 `public/` 临时落地切到 `OSS + asset_object + asset_entity_binding` 正式真相链的边界与槽位约定。 +- [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md):冻结 `M6` 第二批 custom world 图片链迁移口径,明确把 `scene-image / cover-image` 从 Rust SVG 占位切到真实 DashScope 图片生成,并补回 `cover-upload` 的 `cropRect + 16:9 + WebP 压缩`。 - [M6_CHARACTER_ANIMATION_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色动作 `generate / jobs / publish` 接口从旧本地 `public/generated-*` 真相切到 `OSS + asset_object + asset_entity_binding + AI task` 的最小闭环与兼容 contract。 - [M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色动作模板查询与参考视频导入从旧 Node 本地草稿写盘切到 Rust `OSS` 草稿对象的接口 contract、对象键规划与暂不确认 `asset_object` 的边界。 - [M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md](./M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色资产工作流缓存从旧 Node 本地 `workflow-cache.json` 切到 Rust `OSS` JSON 草稿对象的读写 contract、字段归一化与暂不落正式资产表的边界。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 2891bee9..343aacb8 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -76,6 +76,7 @@ dependencies = [ "hmac", "http-body-util", "httpdate", + "image", "module-ai", "module-assets", "module-auth", @@ -106,6 +107,7 @@ dependencies = [ "url", "urlencoding", "uuid", + "webp", ] [[package]] @@ -294,6 +296,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -326,6 +334,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -654,6 +664,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -850,6 +869,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "hashbrown" version = "0.12.3" @@ -1169,6 +1194,32 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1239,6 +1290,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.95" @@ -1304,6 +1365,16 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libwebp-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733" +dependencies = [ + "cc", + "glob", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1512,6 +1583,16 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1748,6 +1829,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1826,6 +1920,18 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -3508,6 +3614,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1" +dependencies = [ + "image", + "libwebp-sys", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -3884,3 +4000,18 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index dd9df5db..ba00a6be 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -6,9 +6,12 @@ license.workspace = true [dependencies] axum = "0.8" +base64 = "0.22" bytes = "1" dotenvy = "0.15" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +webp = "0.3" module-ai = { path = "../module-ai" } module-assets = { path = "../module-assets" } module-auth = { path = "../module-auth" } @@ -28,7 +31,7 @@ shared-contracts = { path = "../shared-contracts" } shared-kernel = { path = "../shared-kernel" } shared-logging = { path = "../shared-logging" } spacetime-client = { path = "../spacetime-client" } -tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] } tokio-stream = "0.1" time = { version = "0.3", features = ["formatting"] } tower-http = { version = "0.6", features = ["trace"] } diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index c287e2b0..7b8513a3 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -54,6 +54,9 @@ pub struct AppConfig { pub llm_request_timeout_ms: u64, pub llm_max_retries: u32, pub llm_retry_backoff_ms: u64, + pub dashscope_base_url: String, + pub dashscope_api_key: Option, + pub dashscope_image_request_timeout_ms: u64, pub slow_request_threshold_ms: u64, } @@ -105,6 +108,9 @@ impl Default for AppConfig { llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS, llm_max_retries: DEFAULT_MAX_RETRIES, llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, + dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(), + dashscope_api_key: None, + dashscope_image_request_timeout_ms: 150_000, slow_request_threshold_ms: 1_000, } } @@ -307,6 +313,18 @@ impl AppConfig { config.llm_retry_backoff_ms = llm_retry_backoff_ms; } + if let Some(dashscope_base_url) = read_first_non_empty_env(&["DASHSCOPE_BASE_URL"]) { + config.dashscope_base_url = dashscope_base_url; + } + + config.dashscope_api_key = read_first_non_empty_env(&["DASHSCOPE_API_KEY"]); + + if let Some(dashscope_image_request_timeout_ms) = + read_first_positive_u64_env(&["DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS"]) + { + config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms; + } + if let Some(slow_request_threshold_ms) = read_first_positive_u64_env(&["GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS"]) { diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index cb53bd71..655bc5a5 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -1,4 +1,7 @@ -use std::collections::BTreeMap; +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; use axum::{ Json, @@ -6,15 +9,22 @@ use axum::{ http::StatusCode, response::Response, }; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use image::{DynamicImage, GenericImageView, imageops::FilterType}; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; use platform_llm::{LlmMessage, LlmTextRequest}; -use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; +use platform_oss::{ + LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, + OssSignedGetObjectUrlRequest, +}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use spacetime_client::SpacetimeClientError; +use tokio::time::sleep; +use webp::Encoder as WebpEncoder; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, @@ -50,15 +60,29 @@ pub(crate) struct CustomWorldSceneImageRequest { prompt: Option, #[serde(default)] size: Option, + #[serde(default)] + negative_prompt: Option, + #[serde(default)] + reference_image_src: Option, + #[serde(default)] + user_prompt: Option, + #[serde(default)] + profile: Option, + #[serde(default)] + landmark: Option, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CustomWorldCoverImageRequest { - profile: Value, + profile: CoverProfileInput, #[serde(default)] user_prompt: Option, #[serde(default)] + reference_image_src: Option, + #[serde(default)] + character_role_ids: Vec, + #[serde(default)] size: Option, } @@ -70,6 +94,7 @@ pub(crate) struct CustomWorldCoverUploadRequest { #[serde(default)] world_name: Option, image_data_url: String, + crop_rect: CustomWorldCoverCropRect, } #[derive(Clone, Debug, Serialize)] @@ -104,6 +129,215 @@ struct PreparedAssetUpload { source_job_id: Option, } +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SceneImageProfileInput { + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + subtitle: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + tone: Option, + #[serde(default)] + player_goal: Option, + #[serde(default)] + setting_text: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SceneImageLandmarkInput { + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + #[serde(default)] + danger_level: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoverRoleInput { + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + title: Option, + #[serde(default)] + role: Option, + #[serde(default)] + description: Option, + #[serde(default)] + image_src: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoverCampInput { + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + #[serde(default)] + image_src: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoverLandmarkInput { + #[serde(default)] + #[allow(dead_code)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + #[serde(default)] + image_src: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoverActInput { + #[serde(default)] + #[allow(dead_code)] + id: Option, + #[serde(default)] + title: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + background_image_src: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoverSceneChapterInput { + #[serde(default)] + #[allow(dead_code)] + id: Option, + #[serde(default)] + #[allow(dead_code)] + scene_id: Option, + #[serde(default)] + #[allow(dead_code)] + title: Option, + #[serde(default)] + #[allow(dead_code)] + summary: Option, + #[serde(default)] + acts: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoverProfileInput { + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + subtitle: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + tone: Option, + #[serde(default)] + player_goal: Option, + #[serde(default)] + setting_text: Option, + #[serde(default)] + camp: Option, + #[serde(default)] + landmarks: Vec, + #[serde(default)] + playable_npcs: Vec, + #[serde(default)] + story_npcs: Vec, + #[serde(default)] + scene_chapter_blueprints: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CustomWorldCoverCropRect { + x: f64, + y: f64, + width: f64, + height: f64, +} + +struct DashScopeSettings { + base_url: String, + api_key: String, + request_timeout_ms: u64, +} + +struct DashScopeGeneratedImage { + image_url: String, + task_id: String, + actual_prompt: Option, +} + +struct DownloadedRemoteImage { + mime_type: String, + extension: String, + bytes: Vec, +} + +struct CoverPromptContext { + opening_act_title: String, + opening_act_summary: String, + role_summary: String, + story_role_summary: String, + landmark_summary: String, +} + +struct NormalizedSceneImageRequest { + profile_id: Option, + world_name: String, + entity_id: String, + size: String, + prompt: String, + negative_prompt: String, + reference_image_src: Option, + model: String, +} + +#[derive(Debug)] +struct NormalizedCropRect { + left: u32, + top: u32, + width: u32, + height: u32, +} + +#[derive(Debug)] +struct OptimizedCoverUpload { + mime_type: String, + extension: String, + bytes: Vec, +} + +const TEXT_TO_IMAGE_SCENE_MODEL: &str = "wan2.2-t2i-flash"; +const REFERENCE_IMAGE_SCENE_MODEL: &str = "qwen-image-2.0"; +const TEXT_TO_IMAGE_COVER_MODEL: &str = "wan2.2-t2i-flash"; +const REFERENCE_IMAGE_COVER_MODEL: &str = "qwen-image-2.0"; +const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头"; +const COVER_OUTPUT_WIDTH: u32 = 1600; +const COVER_OUTPUT_HEIGHT: u32 = 900; +const COVER_UPLOAD_MAX_BYTES: usize = 10 * 1024 * 1024; +const COVER_OUTPUT_MAX_BYTES: usize = (1.5 * 1024.0 * 1024.0) as usize; +const COVER_MIN_RATIO: f64 = 1.7; +const COVER_MAX_RATIO: f64 = 1.8; + pub async fn generate_custom_world_entity( State(state): State, Extension(request_context): Extension, @@ -193,49 +427,87 @@ pub async fn generate_custom_world_scene_image( })?; let owner_user_id = authenticated.claims().user_id().to_string(); - let profile_id = trim_to_option(payload.profile_id.as_deref()); - let world_name = - trim_to_option(payload.world_name.as_deref()).unwrap_or_else(|| "world".to_string()); - let landmark_id = trim_to_option(payload.landmark_id.as_deref()); - let landmark_name = - trim_to_option(payload.landmark_name.as_deref()).unwrap_or_else(|| "scene".to_string()); - let entity_id = landmark_id.clone().unwrap_or_else(|| landmark_name.clone()); - let size = payload - .size - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("1280*720") - .to_string(); - let prompt = trim_to_option(payload.prompt.as_deref()); - let asset_id = format!("custom-scene-{}", current_utc_millis()); - let svg = build_placeholder_svg( - &size, - prompt - .as_deref() - .or(Some(landmark_name.as_str())) - .unwrap_or("scene"), + let normalized = normalize_scene_image_request(payload) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + let settings = require_dashscope_settings(&state) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + let http_client = build_dashscope_http_client(&settings) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + let reference_image = + if let Some(reference_image_src) = normalized.reference_image_src.as_deref() { + Some( + resolve_reference_image_as_data_url( + &state, + &http_client, + reference_image_src, + "referenceImageSrc", + ) + .await + .map_err(|error| custom_world_ai_error_response(&request_context, error))?, + ) + } else { + None + }; + let generated = if let Some(reference_image) = reference_image.as_deref() { + create_reference_image_generation( + &http_client, + &settings, + REFERENCE_IMAGE_SCENE_MODEL, + normalized.prompt.as_str(), + normalized.size.as_str(), + &[reference_image.to_string()], + Some(normalized.negative_prompt.as_str()), + "创建参考图场景编辑任务失败", + "参考图场景编辑未返回图片地址", + "scene-edit", + ) + .await + } else { + create_text_to_image_generation( + &http_client, + &settings, + TEXT_TO_IMAGE_SCENE_MODEL, + normalized.prompt.as_str(), + Some(normalized.negative_prompt.as_str()), + normalized.size.as_str(), + "创建场景图片生成任务失败", + "查询场景图片任务失败", + "场景图片生成任务失败", + "场景图片生成超时或未返回图片地址", + ) + .await + } + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + let downloaded = download_remote_image( + &http_client, + generated.image_url.as_str(), + "下载生成图片失败", ) - .into_bytes(); + .await + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + let asset_id = format!("custom-scene-{}", current_utc_millis()); let upload = PreparedAssetUpload { prefix: LegacyAssetPrefix::CustomWorldScenes, path_segments: vec![ sanitize_storage_segment( - profile_id.as_deref().unwrap_or(world_name.as_str()), + normalized + .profile_id + .as_deref() + .unwrap_or(normalized.world_name.as_str()), "world", ), - sanitize_storage_segment(entity_id.as_str(), "scene"), + sanitize_storage_segment(normalized.entity_id.as_str(), "scene"), asset_id.clone(), ], - file_name: "scene.svg".to_string(), - content_type: "image/svg+xml".to_string(), - body: svg, + file_name: format!("scene.{}", downloaded.extension), + content_type: downloaded.mime_type, + body: downloaded.bytes, asset_kind: "scene_image", entity_kind: "custom_world_landmark", - entity_id, - profile_id, + entity_id: normalized.entity_id.clone(), + profile_id: normalized.profile_id.clone(), slot: "scene_image", - source_job_id: Some(asset_id.clone()), + source_job_id: Some(generated.task_id.clone()), }; let asset = persist_custom_world_asset( &state, @@ -245,11 +517,11 @@ pub async fn generate_custom_world_scene_image( image_src: String::new(), asset_id: asset_id.clone(), source_type: "generated".to_string(), - model: Some("rust-oss-placeholder".to_string()), - size: Some(size), - task_id: Some(asset_id), - prompt: prompt.clone(), - actual_prompt: prompt, + model: Some(normalized.model), + size: Some(normalized.size), + task_id: Some(generated.task_id), + prompt: Some(normalized.prompt), + actual_prompt: generated.actual_prompt, }, ) .await @@ -275,42 +547,92 @@ pub async fn generate_custom_world_cover_image( })?; let owner_user_id = authenticated.claims().user_id().to_string(); - let profile = payload.profile.as_object().cloned().unwrap_or_default(); - let profile_id = read_string_field(&profile, "id"); - let world_name = read_string_field(&profile, "name").unwrap_or_else(|| "world".to_string()); + let profile_id = trim_to_option(payload.profile.id.as_deref()); + let world_name = + trim_to_option(payload.profile.name.as_deref()).unwrap_or_else(|| "world".to_string()); let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone()); - let size = payload - .size - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("1600*900") - .to_string(); - let prompt = trim_to_option(payload.user_prompt.as_deref()); - let asset_id = format!("custom-cover-{}", current_utc_millis()); - let svg = build_placeholder_svg( - &size, - prompt - .as_deref() - .or(Some(world_name.as_str())) - .unwrap_or("cover"), + let size = trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1600*900".to_string()); + let settings = require_dashscope_settings(&state) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + let http_client = build_dashscope_http_client(&settings) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + let reference_sources = collect_cover_reference_image_sources( + &payload.profile, + &payload.character_role_ids, + payload.reference_image_src.as_deref().unwrap_or_default(), + ); + let prompt = build_custom_world_cover_image_prompt( + &payload.profile, + &payload.character_role_ids, + payload.user_prompt.as_deref().unwrap_or_default(), + !reference_sources.is_empty(), + ); + let mut reference_images = Vec::with_capacity(reference_sources.len()); + for source in &reference_sources { + reference_images.push( + resolve_reference_image_as_data_url( + &state, + &http_client, + source.as_str(), + "referenceImageSrc", + ) + .await + .map_err(|error| custom_world_ai_error_response(&request_context, error))?, + ); + } + let generated = if reference_images.is_empty() { + create_text_to_image_generation( + &http_client, + &settings, + TEXT_TO_IMAGE_COVER_MODEL, + prompt.as_str(), + None, + size.as_str(), + "创建作品封面生成任务失败", + "查询作品封面任务失败", + "作品封面生成任务失败", + "作品封面生成超时或未返回图片地址", + ) + .await + } else { + create_reference_image_generation( + &http_client, + &settings, + REFERENCE_IMAGE_COVER_MODEL, + prompt.as_str(), + size.as_str(), + &reference_images, + None, + "创建参考图封面任务失败", + "封面生成未返回图片地址", + "cover-edit", + ) + .await + } + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + let downloaded = download_remote_image( + &http_client, + generated.image_url.as_str(), + "下载作品封面失败", ) - .into_bytes(); + .await + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + let asset_id = format!("custom-cover-{}", current_utc_millis()); let upload = PreparedAssetUpload { prefix: LegacyAssetPrefix::CustomWorldCovers, path_segments: vec![ sanitize_storage_segment(entity_id.as_str(), "world"), asset_id.clone(), ], - file_name: "cover.svg".to_string(), - content_type: "image/svg+xml".to_string(), - body: svg, + file_name: format!("cover.{}", downloaded.extension), + content_type: downloaded.mime_type, + body: downloaded.bytes, asset_kind: "custom_world_cover", entity_kind: "custom_world_profile", entity_id, profile_id, slot: "cover", - source_job_id: Some(asset_id.clone()), + source_job_id: Some(generated.task_id.clone()), }; let asset = persist_custom_world_asset( &state, @@ -320,11 +642,15 @@ pub async fn generate_custom_world_cover_image( image_src: String::new(), asset_id: asset_id.clone(), source_type: "generated".to_string(), - model: Some("rust-oss-placeholder".to_string()), + model: Some(if reference_images.is_empty() { + TEXT_TO_IMAGE_COVER_MODEL.to_string() + } else { + REFERENCE_IMAGE_COVER_MODEL.to_string() + }), size: Some(size), - task_id: Some(asset_id), - prompt: prompt.clone(), - actual_prompt: prompt, + task_id: Some(generated.task_id), + prompt: Some(prompt), + actual_prompt: generated.actual_prompt, }, ) .await @@ -358,28 +684,23 @@ pub async fn upload_custom_world_cover_image( })), ) })?; + let optimized = optimize_uploaded_cover_image(&parsed, &payload.crop_rect) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let owner_user_id = authenticated.claims().user_id().to_string(); let profile_id = trim_to_option(payload.profile_id.as_deref()); let world_name = trim_to_option(payload.world_name.as_deref()).unwrap_or_else(|| "world".to_string()); let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone()); let asset_id = format!("custom-cover-upload-{}", current_utc_millis()); - let file_name = match parsed.mime_type.as_str() { - "image/png" => "cover.png", - "image/webp" => "cover.webp", - "image/svg+xml" => "cover.svg", - _ => "cover.jpg", - } - .to_string(); let upload = PreparedAssetUpload { prefix: LegacyAssetPrefix::CustomWorldCovers, path_segments: vec![ sanitize_storage_segment(entity_id.as_str(), "world"), asset_id.clone(), ], - file_name, - content_type: parsed.mime_type, - body: parsed.bytes, + file_name: format!("cover.{}", optimized.extension), + content_type: optimized.mime_type, + body: optimized.bytes, asset_kind: "custom_world_cover", entity_kind: "custom_world_profile", entity_id, @@ -704,57 +1025,1240 @@ fn build_landmark_fallback(world_name: &str) -> Value { }) } -fn build_placeholder_svg(size: &str, label: &str) -> String { - let (width, height) = parse_size(size); - format!( - r##" - - - - - - - - - - -{title} -Rust OSS placeholder -"##, - width = width, - height = height, - cx1 = width / 3, - cy1 = height / 3, - r1 = (width.min(height) / 7).max(24), - cx2 = width * 3 / 4, - cy2 = height / 4, - r2 = (width.min(height) / 9).max(18), - font_main = (width.min(height) / 12).max(20), - font_sub = (width.min(height) / 24).max(12), - title = escape_svg_text(label), +fn normalize_scene_image_request( + payload: CustomWorldSceneImageRequest, +) -> Result { + let profile = payload.profile.unwrap_or_default(); + let landmark = payload.landmark.unwrap_or_default(); + let reference_image_src = trim_to_option(payload.reference_image_src.as_deref()); + let profile_id = trim_to_option(payload.profile_id.as_deref()) + .or_else(|| trim_to_option(profile.id.as_deref())); + let world_name = trim_to_option(payload.world_name.as_deref()) + .or_else(|| trim_to_option(profile.name.as_deref())) + .unwrap_or_else(|| "world".to_string()); + let landmark_id = trim_to_option(payload.landmark_id.as_deref()) + .or_else(|| trim_to_option(landmark.id.as_deref())); + let landmark_name = trim_to_option(payload.landmark_name.as_deref()) + .or_else(|| trim_to_option(landmark.name.as_deref())); + + if landmark_id.is_none() && landmark_name.is_none() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": "landmarkName 或 landmarkId 至少要提供一个", + })), + ); + } + + let prompt = trim_to_option(payload.prompt.as_deref()).unwrap_or_else(|| { + build_custom_world_scene_image_prompt( + &profile, + &landmark, + payload.user_prompt.as_deref().unwrap_or_default(), + reference_image_src.is_some(), + landmark_name.as_deref(), + world_name.as_str(), + ) + }); + if prompt.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": "prompt 不能为空", + })), + ); + } + + Ok(NormalizedSceneImageRequest { + profile_id, + world_name, + entity_id: landmark_id + .or(landmark_name) + .unwrap_or_else(|| "scene".to_string()), + size: trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1280*720".to_string()), + prompt, + negative_prompt: trim_to_option(payload.negative_prompt.as_deref()) + .unwrap_or_else(|| DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT.to_string()), + reference_image_src: reference_image_src.clone(), + model: if reference_image_src.is_some() { + REFERENCE_IMAGE_SCENE_MODEL.to_string() + } else { + TEXT_TO_IMAGE_SCENE_MODEL.to_string() + }, + }) +} + +fn build_custom_world_scene_image_prompt( + profile: &SceneImageProfileInput, + landmark: &SceneImageLandmarkInput, + user_prompt: &str, + has_reference_image: bool, + fallback_landmark_name: Option<&str>, + fallback_world_name: &str, +) -> String { + let world_name = clamp_scene_image_text( + trim_to_option(profile.name.as_deref()) + .unwrap_or_else(|| fallback_world_name.to_string()) + .as_str(), + 18, + ); + let world_subtitle = clamp_scene_image_text( + trim_to_option(profile.subtitle.as_deref()) + .unwrap_or_default() + .as_str(), + 18, + ); + let world_tone = clamp_scene_image_text( + trim_to_option(profile.tone.as_deref()) + .unwrap_or_default() + .as_str(), + 48, + ); + let world_goal = clamp_scene_image_text( + trim_to_option(profile.player_goal.as_deref()) + .unwrap_or_default() + .as_str(), + 48, + ); + let world_summary = clamp_scene_image_text( + trim_to_option(profile.summary.as_deref()) + .unwrap_or_default() + .as_str(), + 72, + ); + let world_setting = clamp_scene_image_text( + trim_to_option(profile.setting_text.as_deref()) + .unwrap_or_default() + .as_str(), + 72, + ); + let landmark_name = clamp_scene_image_text( + trim_to_option(landmark.name.as_deref()) + .or_else(|| fallback_landmark_name.map(ToOwned::to_owned)) + .unwrap_or_else(|| "未命名场景".to_string()) + .as_str(), + 18, + ); + let landmark_description = clamp_scene_image_text( + trim_to_option(landmark.description.as_deref()) + .unwrap_or_default() + .as_str(), + 96, + ); + let requested_visual = clamp_scene_image_text(user_prompt, 120); + let danger_mood = describe_danger_level( + trim_to_option(landmark.danger_level.as_deref()) + .unwrap_or_default() + .as_str(), + ); + + vec![ + "为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。".to_string(), + "画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。".to_string(), + "下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。".to_string(), + "下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。".to_string(), + "下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。".to_string(), + if has_reference_image { + "已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。".to_string() + } else { + String::new() + }, + format!( + "世界:{}{}。", + if world_name.is_empty() { + "未命名世界" + } else { + world_name.as_str() + }, + if world_subtitle.is_empty() { + String::new() + } else { + format!(",{world_subtitle}") + } + ), + conditional_prompt_line("玩家设定", world_setting.as_str()), + conditional_prompt_line("世界概述", world_summary.as_str()), + conditional_prompt_line("整体基调", world_tone.as_str()), + conditional_prompt_line("玩家目标关联", world_goal.as_str()), + format!( + "场景名称:{}。", + if landmark_name.is_empty() { + "未命名场景" + } else { + landmark_name.as_str() + } + ), + conditional_prompt_line("场景描述", landmark_description.as_str()), + conditional_prompt_line("本次想要生成的画面内容", requested_visual.as_str()), + format!("{danger_mood}。"), + "不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。".to_string(), + ] + .into_iter() + .filter(|line| !line.is_empty()) + .collect::>() + .join("") +} + +fn require_dashscope_settings(state: &AppState) -> Result { + // Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。 + let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); + if base_url.is_empty() { + return Err( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "dashscope", + "reason": "DASHSCOPE_BASE_URL 未配置", + })), + ); + } + + let api_key = state + .config + .dashscope_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "dashscope", + "reason": "DASHSCOPE_API_KEY 未配置", + })) + })?; + + Ok(DashScopeSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1), + }) +} + +fn build_dashscope_http_client(settings: &DashScopeSettings) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms)) + .build() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "dashscope", + "message": format!("构造 DashScope HTTP 客户端失败:{error}"), + })) + }) +} + +async fn resolve_reference_image_as_data_url( + state: &AppState, + http_client: &reqwest::Client, + source: &str, + field: &str, +) -> Result { + let trimmed = source.trim(); + if trimmed.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "field": field, + "message": "参考图不能为空。", + })), + ); + } + + if let Some(parsed) = parse_image_data_url(trimmed) { + return Ok(format!( + "data:{};base64,{}", + parsed.mime_type, + BASE64_STANDARD.encode(parsed.bytes) + )); + } + + if !trimmed.starts_with('/') { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "field": field, + "message": "参考图必须是 Data URL 或 /generated-* 旧路径。", + })), + ); + } + + let object_key = trimmed.trim_start_matches('/'); + if LegacyAssetPrefix::from_object_key(object_key).is_none() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "field": field, + "message": "参考图当前只支持 /generated-* 旧路径。", + })), + ); + } + + // Rust 端不再回读仓库 public 目录,只兼容 Data URL 和现有 generated-* 旧路径。 + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let signed = oss_client + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: object_key.to_string(), + expire_seconds: Some(60), + }) + .map_err(map_custom_world_asset_oss_error)?; + let response = http_client + .get(signed.signed_url) + .send() + .await + .map_err(|error| map_dashscope_request_error(format!("读取参考图失败:{error}")))?; + let status = response.status(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/png") + .to_string(); + let body = response + .bytes() + .await + .map_err(|error| map_dashscope_request_error(format!("读取参考图内容失败:{error}")))?; + if !status.is_success() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": format!("读取参考图失败,状态码:{status}"), + "objectKey": object_key, + })), + ); + } + if body.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": "读取参考图失败:对象内容为空", + "objectKey": object_key, + })), + ); + } + + Ok(format!( + "data:{};base64,{}", + content_type, + BASE64_STANDARD.encode(body) + )) +} + +async fn create_text_to_image_generation( + http_client: &reqwest::Client, + settings: &DashScopeSettings, + model: &str, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + create_error_message: &str, + poll_error_message: &str, + failed_error_message: &str, + timeout_error_message: &str, +) -> Result { + let mut parameters = Map::from_iter([ + ("n".to_string(), json!(1)), + ("size".to_string(), Value::String(size.to_string())), + ("prompt_extend".to_string(), Value::Bool(true)), + ("watermark".to_string(), Value::Bool(false)), + ]); + if let Some(negative_prompt) = negative_prompt + && !negative_prompt.trim().is_empty() + { + parameters.insert( + "negative_prompt".to_string(), + Value::String(negative_prompt.trim().to_string()), + ); + } + + // 文生图链路保持和 Node 旧实现一致:先异步创建任务,再轮询 task 状态。 + let response = http_client + .post(format!( + "{}/services/aigc/text2image/image-synthesis", + settings.base_url + )) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header("X-DashScope-Async", "enable") + .json(&json!({ + "model": model, + "input": { + "prompt": prompt, + }, + "parameters": parameters, + })) + .send() + .await + .map_err(|error| map_dashscope_request_error(format!("{create_error_message}:{error}")))?; + let response_status = response.status(); + let response_text = response + .text() + .await + .map_err(|error| map_dashscope_request_error(format!("{create_error_message}:{error}")))?; + if !response_status.is_success() { + return Err(map_dashscope_upstream_error( + response_text.as_str(), + create_error_message, + )); + } + let response_json = parse_json_payload(response_text.as_str(), create_error_message)?; + + let task_id = extract_task_id(&response_json.payload).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": "场景图片生成任务未返回 task_id", + })) + })?; + let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); + + while Instant::now() < deadline { + let poll_response = http_client + .get(format!("{}/tasks/{}", settings.base_url, task_id)) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .send() + .await + .map_err(|error| { + map_dashscope_request_error(format!("{poll_error_message}:{error}")) + })?; + let poll_status_code = poll_response.status(); + let poll_text = poll_response.text().await.map_err(|error| { + map_dashscope_request_error(format!("{poll_error_message}:{error}")) + })?; + if !poll_status_code.is_success() { + return Err(map_dashscope_upstream_error( + poll_text.as_str(), + poll_error_message, + )); + } + let poll_json = parse_json_payload(poll_text.as_str(), poll_error_message)?; + + let task_status = find_first_string_by_key(&poll_json.payload, "task_status") + .unwrap_or_default() + .trim() + .to_string(); + if task_status == "SUCCEEDED" { + let image_url = extract_image_urls(&poll_json.payload) + .into_iter() + .next() + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": timeout_error_message, + })) + })?; + return Ok(DashScopeGeneratedImage { + image_url, + task_id, + actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"), + }); + } + if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { + return Err(map_dashscope_upstream_error( + poll_text.as_str(), + failed_error_message, + )); + } + + sleep(Duration::from_secs(2)).await; + } + + Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": timeout_error_message, + })), ) } -fn parse_size(size: &str) -> (u32, u32) { - let mut parts = size.split('*'); - let width = parts +async fn create_reference_image_generation( + http_client: &reqwest::Client, + settings: &DashScopeSettings, + model: &str, + prompt: &str, + size: &str, + reference_images: &[String], + negative_prompt: Option<&str>, + create_error_message: &str, + empty_image_error_message: &str, + task_prefix: &str, +) -> Result { + let mut content = reference_images + .iter() + .map(|image| json!({ "image": image })) + .collect::>(); + content.push(json!({ "text": prompt })); + + let mut parameters = Map::from_iter([ + ("n".to_string(), json!(1)), + ("size".to_string(), Value::String(size.to_string())), + ("prompt_extend".to_string(), Value::Bool(true)), + ("watermark".to_string(), Value::Bool(false)), + ]); + if let Some(negative_prompt) = negative_prompt + && !negative_prompt.trim().is_empty() + { + parameters.insert( + "negative_prompt".to_string(), + Value::String(negative_prompt.trim().to_string()), + ); + } + + let response = http_client + .post(format!( + "{}/services/aigc/multimodal-generation/generation", + settings.base_url + )) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&json!({ + "model": model, + "input": { + "messages": [ + { + "role": "user", + "content": content, + } + ] + }, + "parameters": parameters, + })) + .send() + .await + .map_err(|error| map_dashscope_request_error(format!("{create_error_message}:{error}")))?; + let response_status = response.status(); + let response_text = response + .text() + .await + .map_err(|error| map_dashscope_request_error(format!("{create_error_message}:{error}")))?; + if !response_status.is_success() { + return Err(map_dashscope_upstream_error( + response_text.as_str(), + create_error_message, + )); + } + let response_json = parse_json_payload(response_text.as_str(), create_error_message)?; + + let image_url = extract_image_urls(&response_json.payload) + .into_iter() .next() - .and_then(|value| value.trim().parse::().ok()) - .filter(|value| *value > 0) - .unwrap_or(1280); - let height = parts - .next() - .and_then(|value| value.trim().parse::().ok()) - .filter(|value| *value > 0) - .unwrap_or(720); - (width, height) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": empty_image_error_message, + })) + })?; + + Ok(DashScopeGeneratedImage { + image_url, + task_id: format!("{task_prefix}-{}", current_utc_millis()), + actual_prompt: find_first_string_by_key(&response_json.payload, "actual_prompt"), + }) } -fn escape_svg_text(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") +async fn download_remote_image( + http_client: &reqwest::Client, + image_url: &str, + fallback_message: &str, +) -> Result { + let response = http_client + .get(image_url) + .send() + .await + .map_err(|error| map_dashscope_request_error(format!("{fallback_message}:{error}")))?; + let status = response.status(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/jpeg") + .to_string(); + let bytes = response + .bytes() + .await + .map_err(|error| map_dashscope_request_error(format!("{fallback_message}:{error}")))?; + if !status.is_success() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": fallback_message, + "status": status.as_u16(), + })), + ); + } + + let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str()); + Ok(DownloadedRemoteImage { + extension: mime_to_extension(normalized_mime_type.as_str()).to_string(), + mime_type: normalized_mime_type, + bytes: bytes.to_vec(), + }) +} + +fn optimize_uploaded_cover_image( + parsed_data_url: &ParsedImageDataUrl, + crop_rect: &CustomWorldCoverCropRect, +) -> Result { + if parsed_data_url.bytes.len() > COVER_UPLOAD_MAX_BYTES { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": "上传封面原图不能超过 10 MB。", + })), + ); + } + + let image = image::load_from_memory(parsed_data_url.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": format!("无法解析上传封面:{error}"), + })) + })?; + let (source_width, source_height) = image.dimensions(); + if source_width == 0 || source_height == 0 { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": "无法解析上传封面的尺寸。", + })), + ); + } + + let normalized_crop = normalize_cover_crop_rect(source_width, source_height, crop_rect)?; + let resized = image + .crop_imm( + normalized_crop.left, + normalized_crop.top, + normalized_crop.width, + normalized_crop.height, + ) + .resize_exact( + COVER_OUTPUT_WIDTH, + COVER_OUTPUT_HEIGHT, + FilterType::CatmullRom, + ); + // 上传封面固定产出 1600x900 WebP,并按质量档位递减直到满足体积约束。 + let mut encoded = encode_dynamic_image_to_webp(&resized, 90.0)?; + for quality in [84.0, 76.0, 68.0, 60.0] { + if encoded.len() <= COVER_OUTPUT_MAX_BYTES { + break; + } + encoded = encode_dynamic_image_to_webp(&resized, quality)?; + } + if encoded.len() > COVER_OUTPUT_MAX_BYTES { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": "上传封面压缩后仍超过体积限制,请缩小裁剪范围或更换图片。", + })), + ); + } + + Ok(OptimizedCoverUpload { + mime_type: "image/webp".to_string(), + extension: "webp".to_string(), + bytes: encoded, + }) +} + +fn normalize_cover_crop_rect( + source_width: u32, + source_height: u32, + crop_rect: &CustomWorldCoverCropRect, +) -> Result { + let left = crop_rect + .x + .floor() + .clamp(0.0, source_width.saturating_sub(1) as f64) as u32; + let top = crop_rect + .y + .floor() + .clamp(0.0, source_height.saturating_sub(1) as f64) as u32; + let mut width = crop_rect.width.floor().clamp(1.0, source_width as f64) as u32; + let mut height = crop_rect.height.floor().clamp(1.0, source_height as f64) as u32; + width = width.min(source_width.saturating_sub(left)); + height = height.min(source_height.saturating_sub(top)); + + if width == 0 || height == 0 { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": "上传封面裁剪区域不能为空。", + })), + ); + } + + let ratio = width as f64 / height as f64; + if !(COVER_MIN_RATIO..=COVER_MAX_RATIO).contains(&ratio) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": "上传封面裁剪区域必须保持 16:9。", + })), + ); + } + + Ok(NormalizedCropRect { + left, + top, + width, + height, + }) +} + +fn encode_dynamic_image_to_webp(image: &DynamicImage, quality: f32) -> Result, AppError> { + let prepared = if image.color().has_alpha() { + DynamicImage::ImageRgba8(image.to_rgba8()) + } else { + DynamicImage::ImageRgb8(image.to_rgb8()) + }; + let encoder = WebpEncoder::from_image(&prepared).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "custom-world-ai", + "message": format!("构造 WebP 编码器失败:{error}"), + })) + })?; + + Ok(encoder.encode(quality).to_vec()) +} + +fn collect_cover_reference_image_sources( + profile: &CoverProfileInput, + requested_role_ids: &[String], + explicit_reference_image_src: &str, +) -> Vec { + let selected_roles = resolve_selected_roles(profile, requested_role_ids); + let mut sources = Vec::new(); + push_cover_reference_source(&mut sources, explicit_reference_image_src); + if let Some(opening_act) = resolve_opening_act(profile) { + push_cover_reference_source( + &mut sources, + trim_to_option(opening_act.background_image_src.as_deref()) + .unwrap_or_default() + .as_str(), + ); + } + for role in selected_roles { + push_cover_reference_source( + &mut sources, + trim_to_option(role.image_src.as_deref()) + .unwrap_or_default() + .as_str(), + ); + } + if let Some(camp) = profile.camp.as_ref() { + push_cover_reference_source( + &mut sources, + trim_to_option(camp.image_src.as_deref()) + .unwrap_or_default() + .as_str(), + ); + } + if let Some(landmark) = profile.landmarks.first() { + push_cover_reference_source( + &mut sources, + trim_to_option(landmark.image_src.as_deref()) + .unwrap_or_default() + .as_str(), + ); + } + sources.truncate(6); + sources +} + +fn push_cover_reference_source(target: &mut Vec, source: &str) { + let Some(normalized) = trim_to_option(Some(source)) else { + return; + }; + if !(normalized.starts_with('/') || normalized.starts_with("data:")) { + return; + } + // 参考图源需要保留原始 Data URL / generated 路径,不能做截断,否则会破坏下游解码。 + if target.contains(&normalized) { + return; + } + + target.push(normalized); +} + +fn resolve_selected_roles<'a>( + profile: &'a CoverProfileInput, + requested_role_ids: &[String], +) -> Vec<&'a CoverRoleInput> { + let mut selected = Vec::new(); + let mut seen = Vec::new(); + + for role_id in requested_role_ids { + let Some(role_id) = trim_to_option(Some(role_id.as_str())) else { + continue; + }; + if seen.contains(&role_id) { + continue; + } + if let Some(role) = profile + .playable_npcs + .iter() + .find(|role| trim_to_option(role.id.as_deref()).as_deref() == Some(role_id.as_str())) + { + selected.push(role); + seen.push(role_id); + } + if selected.len() >= 3 { + break; + } + } + + if !selected.is_empty() { + return selected; + } + + profile.playable_npcs.iter().take(3).collect() +} + +fn resolve_opening_act(profile: &CoverProfileInput) -> Option<&CoverActInput> { + profile.scene_chapter_blueprints.first()?.acts.first() +} + +fn build_cover_prompt_context( + profile: &CoverProfileInput, + requested_role_ids: &[String], +) -> CoverPromptContext { + let opening_act = resolve_opening_act(profile); + let selected_roles = resolve_selected_roles(profile, requested_role_ids); + let role_summary = selected_roles + .iter() + .map(|role| { + [ + clamp_cover_text( + trim_to_option(role.name.as_deref()) + .unwrap_or_default() + .as_str(), + 18, + ), + clamp_cover_text( + trim_to_option(role.title.as_deref()) + .or_else(|| trim_to_option(role.role.as_deref())) + .unwrap_or_default() + .as_str(), + 24, + ), + clamp_cover_text( + trim_to_option(role.description.as_deref()) + .unwrap_or_default() + .as_str(), + 72, + ), + ] + .into_iter() + .filter(|segment| !segment.is_empty()) + .collect::>() + .join(" / ") + }) + .filter(|segment| !segment.is_empty()) + .collect::>() + .join(";"); + let story_role_summary = profile + .story_npcs + .iter() + .take(4) + .map(|role| { + [ + clamp_cover_text( + trim_to_option(role.name.as_deref()) + .unwrap_or_default() + .as_str(), + 18, + ), + clamp_cover_text( + trim_to_option(role.title.as_deref()) + .or_else(|| trim_to_option(role.role.as_deref())) + .unwrap_or_default() + .as_str(), + 24, + ), + ] + .into_iter() + .filter(|segment| !segment.is_empty()) + .collect::>() + .join(" / ") + }) + .filter(|segment| !segment.is_empty()) + .collect::>() + .join(";"); + let landmark_summary = profile + .landmarks + .iter() + .take(3) + .map(|landmark| { + [ + clamp_cover_text( + trim_to_option(landmark.name.as_deref()) + .unwrap_or_default() + .as_str(), + 18, + ), + clamp_cover_text( + trim_to_option(landmark.description.as_deref()) + .unwrap_or_default() + .as_str(), + 72, + ), + ] + .into_iter() + .filter(|segment| !segment.is_empty()) + .collect::>() + .join(" / ") + }) + .filter(|segment| !segment.is_empty()) + .collect::>() + .join(";"); + + CoverPromptContext { + opening_act_title: clamp_cover_text( + trim_to_option(opening_act.and_then(|act| act.title.as_deref())) + .unwrap_or_default() + .as_str(), + 24, + ), + opening_act_summary: clamp_cover_text( + trim_to_option(opening_act.and_then(|act| act.summary.as_deref())) + .unwrap_or_default() + .as_str(), + 96, + ), + role_summary, + story_role_summary, + landmark_summary, + } +} + +fn build_custom_world_cover_image_prompt( + profile: &CoverProfileInput, + requested_role_ids: &[String], + user_prompt: &str, + has_reference_image: bool, +) -> String { + let opening_scene = profile + .camp + .as_ref() + .map(|camp| { + ( + trim_to_option(camp.name.as_deref()).unwrap_or_default(), + trim_to_option(camp.description.as_deref()).unwrap_or_default(), + ) + }) + .or_else(|| { + profile.landmarks.first().map(|landmark| { + ( + trim_to_option(landmark.name.as_deref()).unwrap_or_default(), + trim_to_option(landmark.description.as_deref()).unwrap_or_default(), + ) + }) + }); + let prompt_context = build_cover_prompt_context(profile, requested_role_ids); + + vec![ + "为 16:9 横版 RPG 作品生成一张高完成度封面图,用于创作列表与作品详情头图。".to_string(), + "画面重点是“开局场景 + 2 到 3 个主要角色”的主视觉,不是纯背景图,也不是 UI 截图。" + .to_string(), + "构图需要有明显前中后景层次,前景角色清晰、主体集中、适合移动端缩略显示。".to_string(), + "不要出现任何标题文字、UI、按钮、水印、logo、边框或排版装饰。".to_string(), + if has_reference_image { + "已提供一张参考图,请尽量沿用其构图、镜头或色彩气质。".to_string() + } else { + String::new() + }, + conditional_prompt_line( + "作品名", + clamp_cover_text( + trim_to_option(profile.name.as_deref()) + .unwrap_or_default() + .as_str(), + 48, + ) + .as_str(), + ), + conditional_prompt_line( + "副标题", + clamp_cover_text( + trim_to_option(profile.subtitle.as_deref()) + .unwrap_or_default() + .as_str(), + 48, + ) + .as_str(), + ), + conditional_prompt_line( + "玩家设定", + clamp_cover_text( + trim_to_option(profile.setting_text.as_deref()) + .unwrap_or_default() + .as_str(), + 96, + ) + .as_str(), + ), + conditional_prompt_line( + "世界概述", + clamp_cover_text( + trim_to_option(profile.summary.as_deref()) + .unwrap_or_default() + .as_str(), + 96, + ) + .as_str(), + ), + conditional_prompt_line( + "整体基调", + clamp_cover_text( + trim_to_option(profile.tone.as_deref()) + .unwrap_or_default() + .as_str(), + 72, + ) + .as_str(), + ), + conditional_prompt_line( + "主线目标", + clamp_cover_text( + trim_to_option(profile.player_goal.as_deref()) + .unwrap_or_default() + .as_str(), + 72, + ) + .as_str(), + ), + conditional_prompt_line("开局第一幕标题", prompt_context.opening_act_title.as_str()), + conditional_prompt_line( + "开局第一幕摘要", + prompt_context.opening_act_summary.as_str(), + ), + opening_scene + .as_ref() + .map(|(name, _)| conditional_prompt_line("开局场景", name.as_str())) + .unwrap_or_default(), + opening_scene + .as_ref() + .map(|(_, description)| conditional_prompt_line("场景描述", description.as_str())) + .unwrap_or_default(), + conditional_prompt_line("关键场景素材", prompt_context.landmark_summary.as_str()), + conditional_prompt_line("需要出现的角色主形象", prompt_context.role_summary.as_str()), + conditional_prompt_line( + "可辅助参考的场景角色", + prompt_context.story_role_summary.as_str(), + ), + conditional_prompt_line("额外要求", user_prompt), + "整体观感要像一张正式作品封面,主体明确,氛围饱满,人物与场景统一。".to_string(), + ] + .into_iter() + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n") +} + +fn clamp_cover_text(value: &str, max_length: usize) -> String { + clamp_text(value, max_length, false) +} + +fn clamp_text(value: &str, max_length: usize, append_ellipsis: bool) -> String { + let normalized = value.split_whitespace().collect::>().join(" "); + let normalized = normalized.trim().to_string(); + if normalized.is_empty() { + return String::new(); + } + if normalized.chars().count() <= max_length { + return normalized; + } + + let kept = normalized + .chars() + .take(if append_ellipsis { + max_length.saturating_sub(1) + } else { + max_length + }) + .collect::() + .trim() + .to_string(); + if append_ellipsis { + format!("{kept}…") + } else { + kept + } +} + +fn parse_json_payload( + raw_text: &str, + fallback_message: &str, +) -> Result { + serde_json::from_str::(raw_text) + .map(|payload| ParsedJsonPayload { payload }) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": format!("{fallback_message}:解析响应失败:{error}"), + })) + }) +} + +fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { + if raw_text.trim().is_empty() { + return fallback_message.to_string(); + } + + if let Ok(parsed) = serde_json::from_str::(raw_text) { + if let Some(message) = parsed + .pointer("/error/message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return message.to_string(); + } + if let Some(message) = parsed + .get("message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return message.to_string(); + } + if let Some(code) = parsed + .pointer("/error/code") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return format!("{fallback_message}({code})"); + } + if let Some(code) = parsed + .get("code") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return format!("{fallback_message}({code})"); + } + } + + raw_text.trim().to_string() +} + +fn map_dashscope_request_error(message: String) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": message, + })) +} + +fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": parse_api_error_message(raw_text, fallback_message), + })) +} + +fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { + match value { + Value::Array(entries) => { + for entry in entries { + collect_strings_by_key(entry, target_key, results); + } + } + Value::Object(object) => { + for (key, nested_value) in object { + if key == target_key { + if let Some(text) = nested_value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + results.push(text.to_string()); + continue; + } + } + collect_strings_by_key(nested_value, target_key, results); + } + } + _ => {} + } +} + +fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { + let mut results = Vec::new(); + collect_strings_by_key(value, target_key, &mut results); + results.into_iter().next() +} + +fn extract_task_id(payload: &Value) -> Option { + find_first_string_by_key(payload, "task_id") +} + +fn extract_image_urls(payload: &Value) -> Vec { + let mut urls = Vec::new(); + collect_strings_by_key(payload, "image", &mut urls); + collect_strings_by_key(payload, "url", &mut urls); + let mut deduped = Vec::new(); + for url in urls { + if !deduped.contains(&url) { + deduped.push(url); + } + } + deduped +} + +fn normalize_downloaded_image_mime_type(content_type: &str) -> String { + let mime_type = content_type + .split(';') + .next() + .map(str::trim) + .unwrap_or("image/jpeg"); + match mime_type { + "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { + mime_type.to_string() + } + _ => "image/jpeg".to_string(), + } +} + +fn mime_to_extension(mime_type: &str) -> &str { + match mime_type { + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + _ => "jpg", + } +} + +fn clamp_scene_image_text(value: &str, max_length: usize) -> String { + clamp_text(value, max_length, true) +} + +fn conditional_prompt_line(prefix: &str, value: &str) -> String { + if value.is_empty() { + String::new() + } else { + format!("{prefix}:{value}。") + } +} + +fn describe_danger_level(danger_level: &str) -> String { + match danger_level.trim().to_ascii_lowercase().as_str() { + "low" | "低" => "气氛相对平静,但暗藏细节张力".to_string(), + "medium" | "中" => "带有明确的探索压力与潜在威胁".to_string(), + "high" | "高" => "危险感强烈,空间中有明显压迫感".to_string(), + "extreme" | "极高" => "极端危险,环境本身就像会吞没闯入者".to_string(), + _ if !danger_level.trim().is_empty() => format!("危险氛围:{}", danger_level.trim()), + _ => "危险气质保持克制但不可忽视".to_string(), + } } fn sanitize_storage_segment(value: &str, fallback: &str) -> String { @@ -877,6 +2381,10 @@ struct ParsedImageDataUrl { bytes: Vec, } +struct ParsedJsonPayload { + payload: Value, +} + #[cfg(test)] mod tests { use super::*; @@ -928,7 +2436,7 @@ mod tests { } #[tokio::test] - async fn scene_image_returns_service_unavailable_when_oss_missing() { + async fn scene_image_returns_service_unavailable_when_dashscope_missing() { let state = AppState::new(AppConfig::default()).expect("state should build"); let request_context = build_request_context("POST /api/custom-world/scene-image"); let authenticated = build_authenticated(&state); @@ -944,10 +2452,15 @@ mod tests { landmark_name: Some("遗迹".to_string()), prompt: Some("测试场景".to_string()), size: Some("1280*720".to_string()), + negative_prompt: None, + reference_image_src: None, + user_prompt: None, + profile: None, + landmark: None, })), ) .await - .expect_err("missing oss should fail"); + .expect_err("missing dashscope should fail"); let payload = read_error_response(response).await; assert_eq!( @@ -956,12 +2469,12 @@ mod tests { ); assert_eq!( payload["error"]["details"]["provider"], - Value::String("aliyun-oss".to_string()) + Value::String("dashscope".to_string()) ); } #[tokio::test] - async fn cover_image_returns_service_unavailable_when_oss_missing() { + async fn cover_image_returns_service_unavailable_when_dashscope_missing() { let state = AppState::new(AppConfig::default()).expect("state should build"); let request_context = build_request_context("POST /api/custom-world/cover-image"); let authenticated = build_authenticated(&state); @@ -971,16 +2484,19 @@ mod tests { Extension(request_context), Extension(authenticated), Ok(Json(CustomWorldCoverImageRequest { - profile: json!({ - "id": "profile_001", - "name": "测试世界" - }), + profile: CoverProfileInput { + id: Some("profile_001".to_string()), + name: Some("测试世界".to_string()), + ..CoverProfileInput::default() + }, user_prompt: Some("测试封面".to_string()), + reference_image_src: None, + character_role_ids: Vec::new(), size: Some("1600*900".to_string()), })), ) .await - .expect_err("missing oss should fail"); + .expect_err("missing dashscope should fail"); let payload = read_error_response(response).await; assert_eq!( @@ -989,7 +2505,7 @@ mod tests { ); assert_eq!( payload["error"]["details"]["provider"], - Value::String("aliyun-oss".to_string()) + Value::String("dashscope".to_string()) ); } @@ -1007,6 +2523,12 @@ mod tests { profile_id: Some("profile_001".to_string()), world_name: Some("测试世界".to_string()), image_data_url: "not-a-data-url".to_string(), + crop_rect: CustomWorldCoverCropRect { + x: 0.0, + y: 0.0, + width: 160.0, + height: 90.0, + }, })), ) .await @@ -1031,4 +2553,50 @@ mod tests { assert_eq!(parsed.mime_type, "image/png"); assert_eq!(parsed.bytes, b"hello".to_vec()); } + + #[test] + fn push_cover_reference_source_keeps_full_data_url() { + let mut sources = Vec::new(); + let source = format!("data:image/png;base64,{}", "a".repeat(1024)); + + push_cover_reference_source(&mut sources, source.as_str()); + + assert_eq!(sources, vec![source]); + } + + #[test] + fn normalize_cover_crop_rect_rejects_non_sixteen_nine_ratio() { + let error = normalize_cover_crop_rect( + 1920, + 1080, + &CustomWorldCoverCropRect { + x: 0.0, + y: 0.0, + width: 400.0, + height: 400.0, + }, + ) + .expect_err("invalid ratio should fail"); + + assert_eq!(error.code(), "BAD_REQUEST"); + } + + #[test] + fn optimize_uploaded_cover_image_rejects_oversized_source_before_decoding() { + let error = optimize_uploaded_cover_image( + &ParsedImageDataUrl { + mime_type: "image/png".to_string(), + bytes: vec![0; COVER_UPLOAD_MAX_BYTES + 1], + }, + &CustomWorldCoverCropRect { + x: 0.0, + y: 0.0, + width: 160.0, + height: 90.0, + }, + ) + .expect_err("oversized upload should fail"); + + assert_eq!(error.code(), "BAD_REQUEST"); + } }