From f1993eb3888a41845bda8e60634d2c42e1c54ca0 Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 11 Sep 2026 15:33:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 + README.md | 25 +- app/globals.css | 91 ++++++- backend/README.md | 2 +- backend/internal/application/runtime.go | 53 ++++- backend/internal/application/runtime_test.go | 40 +++- backend/internal/billing/a4_test.go | 22 ++ backend/internal/billing/defaults.go | 14 +- backend/internal/billing/defaults_test.go | 2 + backend/internal/billing/minimax.go | 49 ++++ backend/internal/billing/service.go | 5 + backend/internal/billing/service_test.go | 17 ++ backend/internal/billing/settlement_test.go | 23 ++ backend/internal/httpapi/httpapi.go | 1 + backend/internal/httpapi/openapi.go | 8 +- backend/internal/httpapi/usage.go | 2 +- backend/internal/jobs/provider.go | 87 ++++++- backend/internal/jobs/provider_test.go | 93 ++++++++ backend/internal/orchestration/creation.go | 2 +- .../internal/orchestration/creation_test.go | 6 + backend/internal/orchestration/settlement.go | 124 +++++++++- .../internal/orchestration/settlement_test.go | 22 ++ backend/internal/postgres/database.go | 1 + backend/internal/postgres/database_test.go | 5 +- backend/internal/providers/a4_test.go | 51 ++++ backend/internal/providers/adapters.go | 7 + backend/internal/providers/minimax.go | 124 ++++++++++ .../internal/providers/minimax_diagnostics.go | 114 +++++++++ .../providers/minimax_diagnostics_test.go | 98 ++++++++ backend/internal/providers/provider.go | 61 ++++- backend/internal/providers/providers_test.go | 47 ++++ backend/internal/settings/service.go | 22 +- backend/internal/settings/service_test.go | 21 +- backend/internal/usage/report.go | 4 + components/asset-manager.tsx | 1 + components/billing-manager.tsx | 1 + components/create-studio.tsx | 108 ++++++--- components/seedream-workspace.tsx | 223 ++++++++++++++++-- components/settings-panel.tsx | 3 + contracts/providers/http-v1.json | 3 +- contracts/settings/runtime-v1.json | 5 + contracts/usage/http-v1.json | 2 +- .../0006_minimax_h3_billing_provider.sql | 13 + deploy/ack/secrets.example.yaml | 1 + docs/DEPLOYMENT.md | 9 +- lib/client/seedream-viewport.ts | 39 +++ lib/evolink/image-client.ts | 4 +- lib/image-size-presets.ts | 35 +++ lib/minimax/client.ts | 161 +++++++++++++ lib/server/app-settings.ts | 25 +- lib/server/billing-catalog.ts | 41 ++++ lib/server/billing-service.ts | 65 ++++- lib/server/data-store.ts | 37 ++- lib/server/database.ts | 11 + lib/server/minimax-billing.ts | 31 +++ lib/server/video-generation-service.ts | 48 +++- lib/types.ts | 11 +- lib/usage.ts | 1 + supabase/schema.sql | 2 +- tests/a4-billing.test.ts | 15 ++ ...create-studio-template-interaction.test.ts | 4 +- tests/data-store-minimax-billing.test.ts | 70 ++++++ tests/database-readiness-contract.test.ts | 3 + tests/image-size-presets.test.ts | 42 ++++ tests/minimax-billing.test.ts | 11 + tests/minimax-client.test.ts | 50 ++++ tests/providers-contract.test.ts | 11 +- tests/seedream-viewport.test.ts | 49 ++++ ...seedream-workspace-layout-contract.test.ts | 20 +- 69 files changed, 2243 insertions(+), 162 deletions(-) create mode 100644 backend/internal/billing/a4_test.go create mode 100644 backend/internal/billing/minimax.go create mode 100644 backend/internal/providers/a4_test.go create mode 100644 backend/internal/providers/minimax.go create mode 100644 backend/internal/providers/minimax_diagnostics.go create mode 100644 backend/internal/providers/minimax_diagnostics_test.go create mode 100644 database/migrations/0006_minimax_h3_billing_provider.sql create mode 100644 lib/client/seedream-viewport.ts create mode 100644 lib/image-size-presets.ts create mode 100644 lib/minimax/client.ts create mode 100644 lib/server/minimax-billing.ts create mode 100644 tests/a4-billing.test.ts create mode 100644 tests/data-store-minimax-billing.test.ts create mode 100644 tests/image-size-presets.test.ts create mode 100644 tests/minimax-billing.test.ts create mode 100644 tests/minimax-client.test.ts create mode 100644 tests/seedream-viewport.test.ts diff --git a/.env.example b/.env.example index cbd796a..989deea 100644 --- a/.env.example +++ b/.env.example @@ -90,6 +90,13 @@ SEEDANCE_RATIO=9:16 # Creation UI/API: Seedance 2.0 supports 4-15 seconds; Seedance 2.5 supports 4-30 seconds. SEEDANCE_DURATION=5 SEEDANCE_RESOLUTION=720p +# Default video engine; the creation page can still select another engine per task. +VIDEO_GENERATE_ENGINE=bailian + +# MiniMax H3 video generation. The API key can also be saved from Settings and +# is then persisted in PostgreSQL with precedence over this environment value. +MINIMAX_API_KEY= +MINIMAX_BASE_URL=https://api.minimax.cn # Aliyun OSS for uploaded/reference assets that Seedance can read. ALI_OSS_ENDPOINT= diff --git a/README.md b/README.md index 8ecbb7a..296a94e 100644 --- a/README.md +++ b/README.md @@ -120,11 +120,11 @@ https://你的域名/api/auth/callback 平台用量页统计登录用户使用真实服务商后成功完成的任务;失败、取消、过期和开放 API 客户端任务不会计入。普通用户点击页头账号 ID 查看快捷周期和最近记录;管理员通过 `/usage` 按日期、组织、账号、功能类型和服务商查看汇总、趋势及明细。 -计费目录由平台维护各接口的标准成本与参数档案,超级管理员只调整上浮倍率。真实生成任务提交时按“基础标准成本 × 参数档位系数 × 任务数量 × 组合倍率”报价并从组织余额冻结,单价、参数、倍率、数量和最终金额会快照到任务;每个任务在创作结果和历史任务中显示扣费状态。普通用户余额不足时会被拒绝提交并提示“余额不足,请先充值”,不会提交服务商;超级管理员仍计算并记录生成费用,但不检查或扣减组织额度,也不产生钱包扣费、退款流水。Seedance 成功后按上游 `usage.completion_tokens` 重新结算,多退少补;没有返回用量时保留冻结金额。组织成员通过 `/billing` 查看组织余额、自己的消耗和账务流水,余额属于组织而不是个人;充值和人工余额调整也只记入组织账本,不设置个人上账归属。 +计费目录由平台维护各接口的标准成本与参数档案,超级管理员只调整上浮倍率。真实生成任务提交时按“基础标准成本 × 参数档位系数 × 任务数量 × 组合倍率”报价并从组织余额冻结,单价、参数、倍率、数量和最终金额会快照到任务;每个任务在创作结果和历史任务中显示扣费状态。普通用户余额不足时会被拒绝提交并提示“余额不足,请先充值”,不会提交服务商;超级管理员仍计算并记录生成费用,但不检查或扣减组织额度,也不产生钱包扣费、退款流水。Seedance 成功后按上游 `usage.completion_tokens` 重新结算,MiniMax H3 成功后按上游实际输出秒数重新结算,多退少补;没有返回用量时保留冻结金额。组织成员通过 `/billing` 查看组织余额、自己的消耗和账务流水,余额属于组织而不是个人;充值和人工余额调整也只记入组织账本,不设置个人上账归属。 当前组织余额由超级管理员直接上账,充值和人工余额调整不选择个人归属;未来接入用户自主支付时,支付成功回调自动入账,不设置人工审核队列。余额不足或未配置对应计费规则时,真实生成任务不会提交给服务商。未绑定组织的开放 API 任务暂保持兼容,不纳入组织余额扣费。 -系统首次打开超管计费中心或提交真实任务时会自动补齐平台标准价格目录,不覆盖已有目录。当前默认目录为:百炼 `wan2.7-image-pro` ¥0.50/张,百炼 `wan2.7-i2v-2026-04-25` 720P ¥0.60/秒、1080P ¥1.00/秒;火山方舟 `doubao-seedance-2-0-260128` 480P/720P/1080P/4K 分别为 ¥0.46/秒、¥0.99/秒、¥2.48/秒、¥5.05/秒,`doubao-seedance-2-5-260628` 480P/720P/1080P 分别为 ¥0.67/秒、¥1.51/秒、¥3.74/秒;EvoLink `gpt-image-2` medium/1K/1:1/无参考图基础估算 ¥0.34/张(固定汇率 1 USD = 7.20 CNY),并列出质量、分辨率、画面比例和参考图数量档位;即梦 `jimeng_seedream46_cvtob` 暂按公开资源包折算参考 ¥0.20/张,官方实时计费以控制台为准;Seedream 5.0 Pro 的 1K/1.5K 基础生图为 ¥0.30/张、2K 为 ¥0.60/张,首张输入图免费,从第 2 张起 ¥0.02/张。Seedream 的基础成本合计后再按平台 `1.2×` 上浮并向上取整到分。参数化报价按基础成本乘以所选参数档位系数,组合倍率取所选档位中的最高倍率;超管只在 `/billing` 调整倍率,标准成本和参数档案由平台维护。 +系统首次打开超管计费中心或提交真实任务时会自动补齐平台标准价格目录,不覆盖已有目录。当前默认目录为:百炼 `wan2.7-image-pro` ¥0.50/张,百炼 `wan2.7-i2v-2026-04-25` 720P ¥0.60/秒、1080P ¥1.00/秒;MiniMax H3 的 768P/2K 分别为 ¥0.50/秒、¥0.80/秒;火山方舟 `doubao-seedance-2-0-260128` 480P/720P/1080P/4K 分别为 ¥0.46/秒、¥0.99/秒、¥2.48/秒、¥5.05/秒,`doubao-seedance-2-5-260628` 480P/720P/1080P 分别为 ¥0.67/秒、¥1.51/秒、¥3.74/秒;EvoLink `gpt-image-2` medium/1K/1:1/无参考图基础估算 ¥0.34/张(固定汇率 1 USD = 7.20 CNY),并列出质量、分辨率、画面比例和参考图数量档位;即梦 `jimeng_seedream46_cvtob` 暂按公开资源包折算参考 ¥0.20/张,官方实时计费以控制台为准;Seedream 5.0 Pro 的 1K/1.5K 基础生图为 ¥0.30/张、2K 为 ¥0.60/张,首张输入图免费,从第 2 张起 ¥0.02/张。各标准成本均按平台 `1.2×` 上浮并向上取整到分。参数化报价按基础成本乘以所选参数档位系数,组合倍率取所选档位中的最高倍率;超管只在 `/billing` 调整倍率,标准成本和参数档案由平台维护。 任务会快照账号、租户和组织归属,用量记录不会随任务、素材或账号删除。统计统一采用 `Asia/Shanghai`,明细不展示提示词、素材或生成结果。 @@ -137,6 +137,12 @@ https://你的域名/api/auth/callback - `evolink`:走 EvoLink GPT Image 2 中转站,提交任务后轮询 EvoLink task 结果。 - `bailian`: uses Alibaba Cloud Model Studio Wan 2.7 for text/reference image generation and image-to-video. +## 视频创作引擎 + +- `seedance`:保留 Seedance 2.0/2.5 多素材视频生成。 +- `bailian`:阿里云百炼 1–2 张首尾帧图生视频。 +- `minimax`:MiniMax H3 第一版,支持文生视频或单张首帧图生视频、768P/2K、4–15 秒;生成结果由任务 Worker 查询后立即转存 OSS。 + ## 即梦图片能力 V1 保留两套独立的火山图片能力: @@ -177,6 +183,14 @@ Seedream 5.0 Pro 使用火山方舟同步接口 `POST /api/v3/images/generations - 任务模块:创作页右侧直接展示任务列表,点击任务可查看完整提示词、输入要素、生成参数、状态和结果。 - 结果保存:图片和视频生成结果会写入资产记录,并在任务详情和任务卡中提供预览与下载。 +### A4 画幅与画布放大编辑 + +- 即梦 4.6、百炼和 EvoLink 的画幅选择以及模板参数新增 `A4 竖版` / `A4 横版`;原有五种比例不变。Seedream 5.0 Pro 继续使用自己的分辨率选项。 +- A4 为 70:99(横版 99:70),即梦 / 百炼默认 1680×2376 或 2376×1680;EvoLink 使用对齐 16px 的 848×1200 或 1200×848,保持现有 1K 像素预算。A4 指生图比例,不是含 DPI、出血或 PDF 的印刷导出。 +- Seedream 交互编辑、指定图层拆分区域的画布支持滚轮 / 按钮缩放(适应画布的 25%~800%),拖动工具 / 空格加拖动 / 鼠标中键平移,`0` 或“适应画布”复位,以及全屏编辑(Esc 退出)。 +- 缩放只改变查看方式,不裁切素材、不修改提交尺寸;框选、点选和涂鸦仍按原图坐标提交。全屏沿用同一画布,点击标注引用会退出全屏并插入提示词。 +- 本轮功能不增加依赖、环境配置或数据库迁移;部署需重新构建前端与 Go 后端。EvoLink A4 报价档位随标准目录自动补齐,不改变已配置的加价倍率。 + 未配置 `SEEDANCE_API_KEY` 时,服务会明确报告凭据未配置,不会生成占位视频或 Seedream 图片。该方舟 API Key 由 Seedance 与 Seedream 5.0 Pro 共用。 ## 环境变量 @@ -226,12 +240,15 @@ cp .env.example .env.local - `SEEDANCE_RATIO`:支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive` - `SEEDANCE_DURATION`:Seedance 2.0 支持 `4` 到 `15` 的整数秒,或 `-1` 让模型自动选择 - `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`;Seedance 2.0 fast 不支持 `1080p` +- `VIDEO_GENERATE_ENGINE`:支持 `seedance`、`bailian` 或 `minimax` +- `MINIMAX_API_KEY`:MiniMax 开放平台 API Key,可在设置页保存到 PostgreSQL 后立即生效 +- `MINIMAX_BASE_URL=https://api.minimax.cn` - `ALI_OSS_*`:用于上传素材和生成结果转存 - `ZHINIAN_DATA_BACKEND`:生产使用 `postgres`,开发可使用 `local` - `DATABASE_URL`:仅服务端读取的 PostgreSQL 连接串 - PostgreSQL 客户端强制使用 `sslmode=disable` 且不读取 CA。ACK 到 RDS 的数据库链路为明文,只应使用 RDS 内网地址,并通过 VPC、安全组和白名单限制访问。 -当 `ZHINIAN_DATA_BACKEND=local` 时,应用使用进程内单实例开发数据层,设置页仍写入本地 `.env.local`。生产 `postgres` 模式把设置页全部 25 项配置写入 `platform_runtime_settings`,不再修改 Pod 本地设置文件;数据库值优先于环境变量和文件回退值。服务商配置、引擎选择和对公账户信息保存后立即生效,认证、计费开关和 OSS 配置保存后需要重启 Go 工作负载。未配置服务商的请求返回 503,不会静默切换到 Mock。如果 OSS 未配置,上传和生成结果会保存到 `.runtime/uploads` 和 `.runtime/generated-results`,并通过 Go 路由提供访问。 +当 `ZHINIAN_DATA_BACKEND=local` 时,应用使用进程内单实例开发数据层,设置页仍写入本地 `.env.local`。生产 `postgres` 模式把设置页全部 27 项配置写入 `platform_runtime_settings`,不再修改 Pod 本地设置文件;数据库值优先于环境变量和文件回退值。服务商配置、引擎选择和对公账户信息保存后立即生效,认证、计费开关和 OSS 配置保存后需要重启 Go 工作负载。未配置服务商的请求返回 503,不会静默切换到 Mock。如果 OSS 未配置,上传和生成结果会保存到 `.runtime/uploads` 和 `.runtime/generated-results`,并通过 Go 路由提供访问。 ## 数据库 @@ -241,7 +258,7 @@ cp .env.example .env.local database/migrations/ ``` -首次部署和每次 schema 变更均按 `database/migrations/*.sql` 中的版本化 SQL 文件手工执行(当前依次执行 0001 至 0005,再加角色授权语句),不部署迁移 Job Pod;执行完成后再滚动工作负载。 +首次部署和每次 schema 变更均按 `database/migrations/*.sql` 中的版本化 SQL 文件手工执行(当前依次执行 0001 至 0006,再加角色授权语句),不部署迁移 Job Pod;执行完成后再滚动工作负载。 当前仍保留必要数据表,供上传、生成任务和用量记录使用: diff --git a/app/globals.css b/app/globals.css index b5a5c92..5212c38 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4422,8 +4422,35 @@ h3 { margin-left: auto; } +.image-size-hint { + color: var(--muted); + font-size: 10px; + line-height: 1.4; +} + +.seedream-view-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 3px; +} + +.seedream-zoom-level { + min-width: 42px; + text-align: center; + color: var(--muted-strong); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.seedream-view-error { + color: var(--danger); + font-size: 12px; +} + .seedream-canvas-viewport { --seedream-canvas-height: clamp(280px, 38vh, 460px); + position: relative; width: 100%; height: var(--seedream-canvas-height); min-height: 0; @@ -4431,6 +4458,8 @@ h3 { display: grid; place-items: center; overflow: hidden; + touch-action: none; + overscroll-behavior: contain; padding: 10px; border: 1px solid var(--line); border-radius: 10px; @@ -4445,21 +4474,71 @@ h3 { } .seedream-canvas-stage { - position: relative; - width: fit-content; - max-width: 100%; + position: absolute; + left: 50%; + top: 50%; + transform-origin: center; line-height: 0; box-shadow: 0 12px 30px rgba(15, 23, 42, 0.13); } .seedream-canvas-stage > img { - width: auto; - max-width: 100%; - max-height: calc(var(--seedream-canvas-height) - 22px); + width: 100%; + height: 100%; + max-width: none; + max-height: none; display: block; user-select: none; } +.seedream-canvas-viewport:focus-visible { + outline: 2px solid var(--green); + outline-offset: 2px; +} + +.seedream-canvas-viewport.pan-ready, +.seedream-canvas-viewport.pan-ready .seedream-annotation-canvas, +.seedream-canvas-viewport.pan-ready .seedream-annotation-shape { + cursor: grab; +} + +.seedream-canvas-viewport.panning, +.seedream-canvas-viewport.panning .seedream-annotation-canvas, +.seedream-canvas-viewport.panning .seedream-annotation-shape { + cursor: grabbing; +} + +/* Native fullscreen promotes this same editor to the top layer. No duplicate + canvas, nested modal, backdrop blur, or lost annotation state. */ +.seedream-workspace:fullscreen { + width: 100%; + height: 100%; + min-height: 0; + margin: 0; + padding: 16px; + border: 0; + border-radius: 0; + overflow: hidden; +} + +.seedream-workspace:fullscreen .seedream-canvas-viewport { + height: auto; + min-height: 120px; + flex: 1 1 auto; +} + +.seedream-workspace:fullscreen .seedream-source-picker { + flex: 0 0 auto; + max-height: 145px; + overflow-y: auto; +} + +.seedream-canvas-note > span { + display: block; + margin-bottom: 3px; + color: var(--green-dark); +} + .seedream-annotation-canvas { position: absolute; inset: 0; diff --git a/backend/README.md b/backend/README.md index 625854c..f22e217 100644 --- a/backend/README.md +++ b/backend/README.md @@ -15,7 +15,7 @@ Implemented Modules: bounded remote import, and Alibaba Cloud OSS adapters. - `jobs`, `providers`, and `orchestration`: provider preparation/protocols, idempotent creation, embedded WorkerLoop, retries, output assets, usage, - refunds, Seedance settlement, and signed Webhooks. + refunds, Seedance/MiniMax H3 settlement, and signed Webhooks. - `billing` and `usage`: integer-fen quote/wallet/ledger/catalog behavior and tenant-scoped reports. - `templates`, `prompt`, `settings`, and `logging`: the remaining compatibility diff --git a/backend/internal/application/runtime.go b/backend/internal/application/runtime.go index 5ba992b..c4749e8 100644 --- a/backend/internal/application/runtime.go +++ b/backend/internal/application/runtime.go @@ -85,10 +85,14 @@ func imageEngine(getenv postgres.Getenv) string { } func videoEngine(getenv postgres.Getenv) string { - if strings.EqualFold(strings.TrimSpace(getenv("VIDEO_GENERATE_ENGINE")), "seedance") { + switch strings.ToLower(strings.TrimSpace(getenv("VIDEO_GENERATE_ENGINE"))) { + case "seedance": return "seedance" + case "minimax": + return "minimax" + default: + return "bailian" } - return "bailian" } func imageProvider(getenv postgres.Getenv) string { @@ -105,10 +109,7 @@ func imageProvider(getenv postgres.Getenv) string { } func videoProvider(getenv postgres.Getenv) string { - if videoEngine(getenv) == "seedance" { - return "seedance" - } - return "bailian" + return videoEngine(getenv) } func imageModel(getenv postgres.Getenv) string { @@ -125,10 +126,14 @@ func imageModel(getenv postgres.Getenv) string { } func videoModel(getenv postgres.Getenv) string { - if videoEngine(getenv) == "seedance" { + switch videoEngine(getenv) { + case "seedance": return firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128") + case "minimax": + return providers.MinimaxH3Model + default: + return firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25") } - return firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25") } func bailianAPIKey(getenv postgres.Getenv) string { @@ -162,6 +167,7 @@ func buildProviderRegistryWithClient(getenv postgres.Getenv, client *http.Client "bailian": providers.NewBailian(providers.Config{BaseURL: bailianNativeBaseURL(getenv), APIKey: bailianAPIKey(getenv), Model: firstNonEmpty(getenv("BAILIAN_IMAGE_MODEL"), "wan2.7-image-pro"), MaxResponseBytes: maximum}, client), "seedance": providers.NewSeedance(providers.Config{BaseURL: firstNonEmpty(getenv("SEEDANCE_BASE_URL"), "https://ark.cn-beijing.volces.com/api/v3"), APIKey: getenv("SEEDANCE_API_KEY"), Model: firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128"), MaxResponseBytes: maximum}, client), "seedream": providers.NewSeedream(providers.Config{BaseURL: firstNonEmpty(getenv("SEEDANCE_BASE_URL"), "https://ark.cn-beijing.volces.com/api/v3"), APIKey: getenv("SEEDANCE_API_KEY"), Model: providers.Seedream50ProModel, MaxResponseBytes: maximum}, client), + "minimax": providers.NewMinimax(providers.Config{BaseURL: firstNonEmpty(getenv("MINIMAX_BASE_URL"), "https://api.minimax.cn"), APIKey: getenv("MINIMAX_API_KEY"), Model: providers.MinimaxH3Model, MaxResponseBytes: maximum}, client), } } @@ -200,6 +206,11 @@ func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget Provider: "bailian", Model: firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25"), }, + "minimax": { + Provider: "minimax", + Model: providers.MinimaxH3Model, + Settings: map[string]any{"ratio": "16:9", "duration": float64(5), "resolution": "768P"}, + }, } } @@ -249,6 +260,9 @@ func providerUnavailableMessages(getenv postgres.Getenv) map[string]string { missing["seedance"] = "Seedance 服务商未配置,请先配置 SEEDANCE_API_KEY。" missing["seedream"] = "Seedream 服务商未配置,请先配置火山方舟 API Key(SEEDANCE_API_KEY)。" } + if strings.TrimSpace(getenv("MINIMAX_API_KEY")) == "" { + missing["minimax"] = "MiniMax 服务商未配置,请先配置 MINIMAX_API_KEY。" + } return missing } @@ -272,6 +286,9 @@ func capabilitySummary(getenv postgres.Getenv) func(context.Context) (any, error primaryVideo["label"] = "Seedance 视频生成" primaryVideo["limits"] = seedanceCapabilityLimits(videoModel(getenv)) primaryVideo["models"] = seedanceModelOptions() + } else if primaryVideoEngine == "minimax" { + primaryVideo["label"] = "MiniMax H3 视频生成" + primaryVideo["limits"] = minimaxVideoCapabilityLimits() } else { primaryVideo["label"] = "百炼图生视频" primaryVideo["limits"] = bailianVideoCapabilityLimits() @@ -280,6 +297,7 @@ func capabilitySummary(getenv postgres.Getenv) func(context.Context) (any, error map[string]any{"id": "image.generate", "label": imageCapabilityLabel(engine), "kind": "image", "engine": engine, "provider": provider, "reqKey": imageModel(getenv)}, primaryVideo, map[string]any{"id": "video.generate.bailian", "label": "百炼图生视频", "kind": "video", "engine": "bailian", "provider": "bailian", "reqKey": firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25"), "limits": bailianVideoCapabilityLimits()}, + map[string]any{"id": "video.generate.minimax", "label": "MiniMax H3 视频生成", "kind": "video", "engine": "minimax", "provider": "minimax", "reqKey": providers.MinimaxH3Model, "limits": minimaxVideoCapabilityLimits()}, }, nil } } @@ -303,11 +321,20 @@ func bailianVideoCapabilityLimits() map[string]any { return map[string]any{"inputImages": map[string]int{"min": 1, "max": 2}, "durationSeconds": map[string]int{"min": 2, "max": 15}, "resolutions": []string{"720P", "1080P"}} } +func minimaxVideoCapabilityLimits() map[string]any { + return map[string]any{ + "inputImages": map[string]int{"min": 0, "max": 1}, + "durationSeconds": map[string]int{"min": 4, "max": 15}, + "ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "adaptive"}, + "resolutions": []string{"768P", "2K"}, + } +} + func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails { image := imageEngine(getenv) imageLabel := map[string]string{"jimeng": "即梦", "evolink": "EvoLink", "bailian": "阿里云百炼", "seedream": "Seedream 5.0 Pro"}[image] video := videoEngine(getenv) - videoLabel := map[string]string{"seedance": "Seedance", "bailian": "阿里云百炼"}[video] + videoLabel := map[string]string{"seedance": "Seedance", "bailian": "阿里云百炼", "minimax": "MiniMax H3"}[video] visualConfigured := strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) != "" && strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) != "" auth, _ := ParseAuthConfig(getenv) bailianKey := bailianAPIKey(getenv) @@ -318,15 +345,20 @@ func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails { authMode = "configured" } } + videoCapability := map[string]any{"id": "video.generate", "label": "视频生成", "engine": video, "engineLabel": videoLabel, "reqKey": videoModel(getenv)} + if video == "seedance" { + videoCapability["models"] = seedanceModelOptions() + } return httpapi.HealthDetails{ VisualAPIMode: providerMode(visualConfigured, "volcengine"), EvolinkMode: providerMode(strings.TrimSpace(getenv("EVOLINK_API_KEY")) != "", "evolink"), SeedanceMode: providerMode(strings.TrimSpace(getenv("SEEDANCE_API_KEY")) != "", "seedance"), BailianMode: providerMode(bailianKey != "", "bailian"), + MinimaxMode: providerMode(strings.TrimSpace(getenv("MINIMAX_API_KEY")) != "", "minimax"), AuthMode: authMode, Capabilities: []any{ map[string]any{"id": "image.generate", "label": imageCapabilityLabel(image), "engine": image, "engineLabel": imageLabel, "reqKey": imageModel(getenv)}, - map[string]any{"id": "video.generate", "label": "视频生成", "engine": video, "engineLabel": videoLabel, "reqKey": videoModel(getenv), "models": seedanceModelOptions()}, + videoCapability, }, } } @@ -353,6 +385,7 @@ var runtimeSettingKeys = []string{ "ALI_OSS_ACCESS_KEY_ID", "ALI_OSS_ACCESS_KEY_SECRET", "ALI_OSS_BUCKET", "ALI_OSS_ENDPOINT", "ALI_OSS_PUBLIC_BASE_URL", "BAILIAN_API_KEY", "BAILIAN_BASE_URL", "BAILIAN_IMAGE_MODEL", "BAILIAN_VIDEO_MODEL", "DATABASE_URL", "DASHSCOPE_API_KEY", "EVOLINK_API_KEY", "EVOLINK_BASE_URL", "EVOLINK_IMAGE_MODEL", "EVOLINK_IMAGE_QUALITY", "IMAGE_GENERATE_ENGINE", + "MINIMAX_API_KEY", "MINIMAX_BASE_URL", "SEEDANCE_API_KEY", "SEEDANCE_MODEL", "VIDEO_GENERATE_ENGINE", "VOLCENGINE_ACCESS_KEY_ID", "VOLCENGINE_SECRET_ACCESS_KEY", "ZHINIAN_AUTH_REQUIRED", "ZHINIAN_AUTH_SESSION_SECRET", "ZHINIAN_BILLING_ACCOUNT_BANK", "ZHINIAN_BILLING_ACCOUNT_NAME", "ZHINIAN_BILLING_ACCOUNT_NUMBER", "ZHINIAN_BILLING_CONTACT", "ZHINIAN_BILLING_REQUIRED", diff --git a/backend/internal/application/runtime_test.go b/backend/internal/application/runtime_test.go index f627b15..effc848 100644 --- a/backend/internal/application/runtime_test.go +++ b/backend/internal/application/runtime_test.go @@ -215,6 +215,32 @@ func TestRuntimeProviderJobBuilderRoutesSeedreamWithoutReplacingJimeng(t *testin } } +func TestRuntimeProviderJobBuilderRoutesMinimaxH3FromDatabaseSettings(t *testing.T) { + values := map[string]string{ + "MINIMAX_API_KEY": "minimax-secret", + "MINIMAX_BASE_URL": "https://api.minimax.test", + "VIDEO_GENERATE_ENGINE": "minimax", + } + getenv := func(name string) string { return values[name] } + builder := configuredProviderJobBuilder(getenv, true) + command, err := builder.Build(context.Background(), "owner", "", "video.generate", "", map[string]any{ + "engine": "minimax", "prompt": "animate", "settings": map[string]any{"duration": 5.0, "resolution": "768P", "ratio": "16:9"}, + }) + if err != nil { + t.Fatal(err) + } + if command.Job.Provider != "minimax" || command.Job.ReqKey != providers.MinimaxH3Model { + t.Fatalf("job=%#v", command.Job) + } + if _, ok := buildProviderRegistryWithClient(getenv, http.DefaultClient, 2<<20)["minimax"].(*providers.Minimax); !ok { + t.Fatalf("minimax adapter is not registered") + } + details := runtimeHealthDetails(getenv) + if details.MinimaxMode != "minimax" { + t.Fatalf("health=%#v", details) + } +} + func TestRuntimeProviderResolverUsesLatestDatabaseCredential(t *testing.T) { var authorizations []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { @@ -279,7 +305,7 @@ func TestProviderUnavailableMessagesIdentifyOnlyMissingCredentials(t *testing.T) "BAILIAN_API_KEY": "bailian", } missing := providerUnavailableMessages(func(name string) string { return values[name] }) - if len(missing) != 1 || !strings.Contains(missing["seedance"], "SEEDANCE_API_KEY") { + if len(missing) != 3 || !strings.Contains(missing["seedance"], "SEEDANCE_API_KEY") || !strings.Contains(missing["seedream"], "SEEDANCE_API_KEY") || !strings.Contains(missing["minimax"], "MINIMAX_API_KEY") { t.Fatalf("missing = %#v", missing) } } @@ -313,6 +339,17 @@ func TestProviderVideoTargetsUseDocumentedSeedanceDefaults(t *testing.T) { } } +func TestProviderVideoTargetsIncludeMinimaxH3Defaults(t *testing.T) { + target := providerVideoTargets(func(string) string { return "" })["minimax"] + if target.Provider != "minimax" || target.Model != providers.MinimaxH3Model { + t.Fatalf("target=%#v", target) + } + want := map[string]any{"ratio": "16:9", "duration": float64(5), "resolution": "768P"} + if !reflect.DeepEqual(target.Settings, want) { + t.Fatalf("settings=%#v want=%#v", target.Settings, want) + } +} + func TestProviderVideoModelsKeepSeedance20And25Available(t *testing.T) { targets := providerVideoModels(func(string) string { return "" }) for _, model := range []string{"doubao-seedance-2-0-260128", "doubao-seedance-2-5-260628"} { @@ -335,6 +372,7 @@ func TestCapabilitySummaryMatchesConfiguredDefaultVideoEngine(t *testing.T) { }{ {name: "Bailian default", engine: "", wantEngine: "bailian", wantProvider: "bailian", wantModel: "wan2.7-i2v-2026-04-25"}, {name: "Seedance configured", engine: "seedance", wantEngine: "seedance", wantProvider: "seedance", wantModel: "doubao-seedance-2-0-260128"}, + {name: "MiniMax configured", engine: "minimax", wantEngine: "minimax", wantProvider: "minimax", wantModel: providers.MinimaxH3Model}, } { t.Run(test.name, func(t *testing.T) { values := map[string]string{"VIDEO_GENERATE_ENGINE": test.engine} diff --git a/backend/internal/billing/a4_test.go b/backend/internal/billing/a4_test.go new file mode 100644 index 0000000..d17b33f --- /dev/null +++ b/backend/internal/billing/a4_test.go @@ -0,0 +1,22 @@ +package billing + +import "testing" + +func TestA4QuoteUsesExisting1KCostAndMarkup(t *testing.T) { + for _, size := range []struct { + width, height int + ratio string + }{{1680, 2376, "70:99"}, {2376, 1680, "99:70"}, {848, 1200, "70:99"}, {1200, 848, "99:70"}} { + dimensions := map[string]any{"width": size.width, "height": size.height} + for _, payload := range []map[string]any{dimensions, {"settings": dimensions}, {"input": dimensions}, {"providerPayload": dimensions}} { + parameters := NormalizeBillingParameters(payload, Parameters{"resolution": "1K", "quality": "medium"}) + if parameters["aspectRatio"] != size.ratio { + t.Fatalf("parameters=%#v", parameters) + } + quote, err := (Catalog{Rules: DefaultBillingPriceRules()}).Quote(QuoteInput{Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2", Source: "platform", Parameters: parameters}) + if err != nil || quote == nil || quote.StandardUnitPriceFen != 34 || quote.MarkupMultiplier != 1.2 || quote.AmountFen != 41 { + t.Fatalf("A4 quote=%#v err=%v", quote, err) + } + } + } +} diff --git a/backend/internal/billing/defaults.go b/backend/internal/billing/defaults.go index 1a76996..8afdfd9 100644 --- a/backend/internal/billing/defaults.go +++ b/backend/internal/billing/defaults.go @@ -35,6 +35,8 @@ func DefaultBillingPriceRules() []PriceRule { {ID: "base-seedance-2-5-480p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-5-260628", VariantKey: "resolution=480p", Unit: UnitVideoSecond, StandardUnitPriceFen: 67, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh#sd25_price")}, {ID: "base-seedance-2-5-720p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-5-260628", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 151, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh#sd25_price")}, {ID: "base-seedance-2-5-1080p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-5-260628", VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 374, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh#sd25_price")}, + {ID: "base-minimax-h3-768p", Provider: "minimax", Capability: "video.generate", ReqKey: MinimaxH3Model, VariantKey: "resolution=768p", Unit: UnitVideoSecond, StandardUnitPriceFen: 50, Note: "MiniMax H3 768P 输出 ¥0.50/秒;首五张输入图免费。", Source: minimaxCatalogSource()}, + {ID: "base-minimax-h3-2k", Provider: "minimax", Capability: "video.generate", ReqKey: MinimaxH3Model, VariantKey: "resolution=2k", Unit: UnitVideoSecond, StandardUnitPriceFen: 80, Note: "MiniMax H3 2K 输出 ¥0.80/秒;首五张输入图免费。", Source: minimaxCatalogSource()}, } for i := range rules { rules[i].Enabled = true @@ -43,6 +45,10 @@ func DefaultBillingPriceRules() []PriceRule { return rules } +func minimaxCatalogSource() map[string]any { + return map[string]any{"url": "https://platform.minimaxi.com/docs/guides/pricing-paygo#视频", "observedAt": "2026-09-04"} +} + func seedreamDimensions() []ParameterDimension { return []ParameterDimension{{ Key: "size", Label: "分辨率", BaselineValue: "1.5K", DefaultValue: "1.5K", @@ -70,7 +76,7 @@ func evolinkDimensions() []ParameterDimension { return []ParameterDimension{ {Key: "quality", BaselineValue: "medium", DefaultValue: "medium", Tiers: tiers("low", .11, "medium", 1.0, "high", 4.0)}, {Key: "resolution", BaselineValue: "1K", DefaultValue: "1K", Tiers: tiers("1K", 1.0, "2K", 4.0, "4K", 8.0)}, - {Key: "aspectRatio", BaselineValue: "1:1", DefaultValue: "1:1", Tiers: tiers("1:1", 1.0, "4:3", 1.0, "3:4", 1.0, "16:9", 1.0, "9:16", 1.0)}, + {Key: "aspectRatio", BaselineValue: "1:1", DefaultValue: "1:1", Tiers: tiers("1:1", 1.0, "4:3", 1.0, "3:4", 1.0, "16:9", 1.0, "9:16", 1.0, "70:99", 1.0, "99:70", 1.0)}, {Key: "referenceImageCount", BaselineValue: 0, DefaultValue: 0, Tiers: []ParameterTier{ {Value: 0, StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}, {Value: "1–4", Match: map[string]any{"min": 1, "max": 4}, StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}, @@ -115,8 +121,8 @@ func NormalizeBillingParameters(payload map[string]any, fallback Parameters) Par out["imageCount"] = float64(17) } - width, widthOK := finiteNumber(first(providerPayload["width"], input["width"])) - height, heightOK := finiteNumber(first(providerPayload["height"], input["height"])) + width, widthOK := finiteNumber(first(providerPayload["width"], input["width"], settings["width"], payload["width"], out["width"])) + height, heightOK := finiteNumber(first(providerPayload["height"], input["height"], settings["height"], payload["height"], out["height"])) if size := normalizedText(first(settings["size"], providerPayload["size"], providerParameters["size"], inputSettings["size"], input["size"])); size != "" { out["size"] = strings.ReplaceAll(size, "×", "*") } else if widthOK && heightOK { @@ -187,7 +193,7 @@ func aspectRatio(width, height float64) string { for _, item := range []struct { value float64 label string - }{{1, "1:1"}, {4.0 / 3, "4:3"}, {16.0 / 9, "16:9"}, {3.0 / 4, "3:4"}, {9.0 / 16, "9:16"}, {21.0 / 9, "21:9"}} { + }{{1, "1:1"}, {4.0 / 3, "4:3"}, {16.0 / 9, "16:9"}, {3.0 / 4, "3:4"}, {9.0 / 16, "9:16"}, {21.0 / 9, "21:9"}, {70.0 / 99, "70:99"}, {99.0 / 70, "99:70"}} { if math.Abs(ratio-item.value) < .02 { return item.label } diff --git a/backend/internal/billing/defaults_test.go b/backend/internal/billing/defaults_test.go index 80fd4b1..641f87d 100644 --- a/backend/internal/billing/defaults_test.go +++ b/backend/internal/billing/defaults_test.go @@ -19,6 +19,8 @@ func TestDefaultBillingPriceRulesFreezeOfficialCatalogMatchKeys(t *testing.T) { "base-seedance-2-5-480p": false, "base-seedance-2-5-720p": false, "base-seedance-2-5-1080p": false, + "base-minimax-h3-768p": false, + "base-minimax-h3-2k": false, } for _, rule := range rules { if _, ok := want[rule.ID]; !ok { diff --git a/backend/internal/billing/minimax.go b/backend/internal/billing/minimax.go new file mode 100644 index 0000000..63ebb2d --- /dev/null +++ b/backend/internal/billing/minimax.go @@ -0,0 +1,49 @@ +package billing + +import ( + "errors" + "math" + "strings" +) + +const MinimaxH3Model = "MiniMax-H3" + +type MinimaxH3ActualAmountInput struct { + Resolution string + OutputSeconds int64 + InputVideoSeconds int64 + InputImageCount int64 + MarkupMultiplier float64 +} + +func MinimaxH3VideoPriceFenPerSecond(resolution string) int64 { + if strings.EqualFold(strings.TrimSpace(resolution), "2K") { + return 80 + } + return 50 +} + +// CalculateMinimaxH3ActualAmountFen follows the official pay-as-you-go H3 +// formula. The first five input images are free; this first product release +// accepts at most one, while keeping the calculator correct for future modes. +func CalculateMinimaxH3ActualAmountFen(input MinimaxH3ActualAmountInput) (int64, error) { + if input.OutputSeconds <= 0 { + return 0, errors.New("MiniMax H3 output seconds must be positive") + } + if input.InputVideoSeconds < 0 || input.InputImageCount < 0 { + return 0, errors.New("MiniMax H3 input usage must not be negative") + } + if input.MarkupMultiplier < 1 { + return 0, errors.New("MiniMax H3 markup multiplier must be at least 1") + } + videoSeconds := input.OutputSeconds + input.InputVideoSeconds + standardAmountFen := videoSeconds * MinimaxH3VideoPriceFenPerSecond(input.Resolution) + if input.InputImageCount > 5 { + standardAmountFen += (input.InputImageCount - 5) * 20 + } + return int64(math.Ceil(float64(standardAmountFen) * input.MarkupMultiplier)), nil +} + +func IsMinimaxH3BillingModel(model string) bool { + return strings.EqualFold(strings.TrimSpace(model), MinimaxH3Model) +} diff --git a/backend/internal/billing/service.go b/backend/internal/billing/service.go index 4468219..982f11c 100644 --- a/backend/internal/billing/service.go +++ b/backend/internal/billing/service.go @@ -109,6 +109,11 @@ func (s *Service) Quote(ctx context.Context, command QuoteCommand) (*Quote, erro quote.Parameters["inputReferencePriceFen"] = float64(max(0, referenceImageCount-1) * 2) return quote, nil } + if command.Provider == "minimax" && IsMinimaxH3BillingModel(command.ReqKey) { + quote.ReservedAmountFen = quote.AmountFen + quote.SettlementStatus = "pending" + return quote, nil + } if command.Provider != "seedance" || !IsSeedanceBillingModel(command.ReqKey) { return quote, nil } diff --git a/backend/internal/billing/service_test.go b/backend/internal/billing/service_test.go index 2923263..8cb4889 100644 --- a/backend/internal/billing/service_test.go +++ b/backend/internal/billing/service_test.go @@ -115,6 +115,23 @@ func TestServiceQuoteUsesSeedance25CatalogAndTokenEstimate(t *testing.T) { } } +func TestServiceQuoteReservesMinimaxH3OfficialRateWithMarkup(t *testing.T) { + store := "eStoreStub{rules: []PriceRule{{ + ID: "minimax-h3-2k", Provider: "minimax", Capability: "video.generate", ReqKey: MinimaxH3Model, + VariantKey: "resolution=2k", Unit: UnitVideoSecond, StandardUnitPriceFen: 80, MarkupMultiplier: 1.2, Enabled: true, + }}} + quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{ + Provider: "minimax", Capability: "video.generate", ReqKey: MinimaxH3Model, + Payload: map[string]any{"settings": map[string]any{"duration": 5.0, "resolution": "2K", "ratio": "16:9"}}, + }) + if err != nil { + t.Fatal(err) + } + if quote.AmountFen != 480 || quote.ReservedAmountFen != 480 || quote.SettlementStatus != "pending" || quote.Parameters["resolution"] != "2k" { + t.Fatalf("quote=%#v", quote) + } +} + func TestServiceQuoteAppliesSeedreamSizeAndReferenceInputPricing(t *testing.T) { store := "eStoreStub{rules: []PriceRule{{ ID: "seedream-5", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, diff --git a/backend/internal/billing/settlement_test.go b/backend/internal/billing/settlement_test.go index 6baf2a9..26888b2 100644 --- a/backend/internal/billing/settlement_test.go +++ b/backend/internal/billing/settlement_test.go @@ -31,6 +31,29 @@ func TestSeedanceActualAmountUsesFrozenTokenPricingAndRoundsUp(t *testing.T) { } } +func TestMinimaxH3ActualAmountUsesOfficialPerSecondPricingAndMarkup(t *testing.T) { + tests := []struct { + resolution string + outputSeconds int64 + inputVideoSeconds int64 + inputImageCount int64 + want int64 + }{ + {resolution: "768P", outputSeconds: 5, inputImageCount: 1, want: 300}, + {resolution: "2K", outputSeconds: 5, inputImageCount: 1, want: 480}, + {resolution: "2k", outputSeconds: 4, inputVideoSeconds: 2, inputImageCount: 7, want: 624}, + } + for _, test := range tests { + got, err := CalculateMinimaxH3ActualAmountFen(MinimaxH3ActualAmountInput{ + Resolution: test.resolution, OutputSeconds: test.outputSeconds, InputVideoSeconds: test.inputVideoSeconds, + InputImageCount: test.inputImageCount, MarkupMultiplier: 1.2, + }) + if err != nil || got != test.want { + t.Fatalf("resolution=%s amount=%d err=%v want=%d", test.resolution, got, err, test.want) + } + } +} + func TestLedgerSettlesSeedanceDeltaWithOneFrozenIdempotencyKey(t *testing.T) { poster := &recordingPoster{result: WalletPosting{LedgerID: "settlement-ledger", CreatedAt: time.Unix(7, 0)}} ledger := Ledger{Poster: poster, NewID: func() string { return "new-ledger" }} diff --git a/backend/internal/httpapi/httpapi.go b/backend/internal/httpapi/httpapi.go index 578ef57..b7a078a 100644 --- a/backend/internal/httpapi/httpapi.go +++ b/backend/internal/httpapi/httpapi.go @@ -41,6 +41,7 @@ type HealthDetails struct { EvolinkMode string `json:"evolinkMode"` SeedanceMode string `json:"seedanceMode"` BailianMode string `json:"bailianMode"` + MinimaxMode string `json:"minimaxMode"` AuthMode string `json:"authMode"` Capabilities []any `json:"capabilities"` } diff --git a/backend/internal/httpapi/openapi.go b/backend/internal/httpapi/openapi.go index 3138529..0c876df 100644 --- a/backend/internal/httpapi/openapi.go +++ b/backend/internal/httpapi/openapi.go @@ -55,7 +55,7 @@ func openAPISchemas() map[string]any { "ownerId": map[string]any{"type": "string", "example": "api:partner-a"}, "externalClientId": map[string]any{"type": "string"}, "capability": openAPIRef("GenerationCapability"), - "provider": map[string]any{"type": "string", "enum": []string{"volcengine-visual", "evolink", "seedance", "seedream", "bailian"}}, + "provider": map[string]any{"type": "string", "enum": []string{"volcengine-visual", "evolink", "seedance", "seedream", "bailian", "minimax"}}, "reqKey": map[string]any{"type": "string"}, "status": openAPIRef("GenerationStatus"), "prompt": map[string]any{"type": "string"}, @@ -94,7 +94,7 @@ func openAPISchemas() map[string]any { "required": []string{"capability"}, "properties": map[string]any{ "capability": openAPIRef("GenerationCapability"), - "engine": map[string]any{"type": "string", "enum": []string{"jimeng", "seedream", "evolink", "bailian", "seedance"}}, + "engine": map[string]any{"type": "string", "enum": []string{"jimeng", "seedream", "evolink", "bailian", "seedance", "minimax"}}, "model": map[string]any{"type": "string", "enum": []string{"doubao-seedance-2-0-260128", "doubao-seedance-2-5-260628"}, "description": "Optional allow-listed Seedance model for video.generate."}, "prompt": map[string]any{"type": "string", "description": "Prompt text. Required for generation except Seedream layer decomposition, where an empty prompt triggers automatic decomposition."}, "inputUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Reference image URLs for image capabilities."}, @@ -105,8 +105,8 @@ func openAPISchemas() map[string]any { "type": "object", "description": "Provider-specific image or video settings.", "properties": map[string]any{ "ratio": map[string]any{"type": "string", "enum": []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"}}, - "duration": map[string]any{"type": "integer", "minimum": 4, "maximum": 30, "description": "Seedance 2.0 supports up to 15 seconds; Seedance 2.5 supports up to 30 seconds."}, - "resolution": map[string]any{"type": "string", "enum": []string{"480p", "720p", "1080p"}}, + "duration": map[string]any{"type": "integer", "minimum": 2, "maximum": 30, "description": "Limits depend on engine: MiniMax H3 supports 4–15 seconds."}, + "resolution": map[string]any{"type": "string", "enum": []string{"480p", "720p", "1080p", "768P", "2K"}}, "size": map[string]any{"type": "string", "enum": []string{"auto", "1K", "1.5K", "2K"}, "description": "Seedream 5.0 Pro output size. auto is available for layer decomposition only."}, "outputFormat": map[string]any{"type": "string", "enum": []string{"png", "jpeg"}}, "optimizeMode": map[string]any{"type": "string", "enum": []string{"standard", "fast"}}, diff --git a/backend/internal/httpapi/usage.go b/backend/internal/httpapi/usage.go index 435044c..813bf6f 100644 --- a/backend/internal/httpapi/usage.go +++ b/backend/internal/httpapi/usage.go @@ -71,7 +71,7 @@ func (h *usageHandler) admin(w http.ResponseWriter, r *http.Request) { writeAPIError(w, 400, "不支持的功能类型。") return } - if provider != "" && provider != "volcengine-visual" && provider != "evolink" && provider != "seedance" && provider != "seedream" && provider != "bailian" { + if provider != "" && provider != "volcengine-visual" && provider != "evolink" && provider != "seedance" && provider != "seedream" && provider != "bailian" && provider != "minimax" { writeAPIError(w, 400, "不支持的服务商。") return } diff --git a/backend/internal/jobs/provider.go b/backend/internal/jobs/provider.go index 9e74d3d..21bde44 100644 --- a/backend/internal/jobs/provider.go +++ b/backend/internal/jobs/provider.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "math" + "net/http" "net/url" "strings" "time" @@ -104,7 +105,18 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) { if err != nil && job.ProviderTaskID == "" && p.Store != nil { failed := StatusFailed failure := providerSubmissionFailure(err) - return p.Store.UpdateJob(ctx, job.ID, workerPatch(job, Patch{Status: &failed, Error: failure})) + patch := Patch{Status: &failed, Error: failure} + var providerError *providers.ProviderError + if job.Provider == "minimax" && errors.As(err, &providerError) && providerError.Status > 0 { + // Keep only the bounded, redacted diagnostic, never the raw response. + patch.ResponsePayload, _ = json.Marshal(map[string]any{"providerError": map[string]any{ + "status": providerError.Status, "code": providerError.Code, + "requestId": providerError.RequestID, "detail": providerError.Detail, + }}) + patch.SetResponsePayload = true + failure.Code = providerError.Code + } + return p.Store.UpdateJob(ctx, job.ID, workerPatch(job, patch)) } if err != nil { return Job{}, err @@ -160,12 +172,39 @@ const ( func providerSubmissionFailure(err error) *JobError { message := unknownProviderSubmissionMessage var providerError *providers.ProviderError - if errors.As(err, &providerError) && (providerError.Code == "OutputImageSensitiveContentDetected" || strings.HasPrefix(providerError.Code, "OutputImageSensitiveContentDetected.")) { - message = outputImageSafetyMessage + if errors.As(err, &providerError) { + if providerError.Code == "OutputImageSensitiveContentDetected" || strings.HasPrefix(providerError.Code, "OutputImageSensitiveContentDetected.") { + message = outputImageSafetyMessage + } else if strings.HasPrefix(providerError.Operation, "minimax ") { + message = minimaxSubmissionFailureMessage(providerError) + } } return &JobError{Message: message, Retryable: false} } +func minimaxSubmissionFailureMessage(providerError *providers.ProviderError) string { + switch providerError.Status { + case http.StatusBadRequest: + return "MiniMax 请求参数不符合要求,请检查提示词、素材和生成参数。" + case http.StatusUnauthorized: + return "MiniMax 鉴权失败,请检查 API Key 是否正确且已开通 H3。" + case http.StatusForbidden: + return "MiniMax API Key 无 H3 调用权限,请确认已购买并开通该模型。" + case http.StatusPaymentRequired: + return "MiniMax API 账户余额不足,请充值后重试。" + case http.StatusNotFound: + return "MiniMax H3 接口或模型不可用,请检查 Base URL 和账号模型权限。" + case http.StatusUnprocessableEntity: + return "视频描述或输入素材触发内容安全审核,请调整后重试。" + case http.StatusTooManyRequests: + return "MiniMax 请求频率过高,请稍后重试。" + case http.StatusInternalServerError: + return "MiniMax 服务暂时不可用,请稍后重试。" + default: + return unknownProviderSubmissionMessage + } +} + type providerFailureDiagnostic struct { JobID string Provider string @@ -368,7 +407,7 @@ func (b ProviderJobBuilder) target(capability string, rawEngine, rawModel any) ( return target, engine, nil } if engine != "" { - if engine != "seedance" && engine != "bailian" { + if engine != "seedance" && engine != "bailian" && engine != "minimax" { return ProviderTarget{}, "", invalidPreparation("unsupported video engine") } target, ok := b.VideoEngines[engine] @@ -394,7 +433,7 @@ func firstConfiguredEngine(configured, provider, capabilityKind string) string { case "evolink", "bailian", "seedream": return provider } - } else if provider == "seedance" || provider == "bailian" { + } else if provider == "seedance" || provider == "bailian" || provider == "minimax" { return provider } return "" @@ -747,6 +786,44 @@ func videoSettings(engine, model string, defaults map[string]any, raw any, mater } input = merged settings := map[string]any{} + if engine == "minimax" { + if model != providers.MinimaxH3Model { + return nil, invalidPreparation("unsupported MiniMax video model") + } + if len(materials) > 1 { + return nil, invalidPreparation("MiniMax H3 first release supports at most one first-frame image") + } + if len(materials) == 1 && materials[0].Type != "image" { + return nil, invalidPreparation("MiniMax H3 first release supports image material only") + } + duration := float64(5) + if input["duration"] != nil { + var ok bool + duration, ok = finiteNumber(input["duration"]) + if !ok || math.Trunc(duration) != duration || duration < 4 || duration > 15 { + return nil, invalidPreparation("MiniMax H3 video duration must be an integer between 4 and 15 seconds") + } + } + resolution := strings.ToUpper(stringValue(input["resolution"])) + if resolution == "" { + resolution = "768P" + } + if resolution != "768P" && resolution != "2K" { + return nil, invalidPreparation("MiniMax H3 video resolution must be 768P or 2K") + } + ratio := stringValue(input["ratio"]) + if len(materials) == 1 { + ratio = "adaptive" + } else { + if ratio == "" { + ratio = "16:9" + } + if ratio == "adaptive" || !oneOf(ratio, "21:9", "16:9", "4:3", "1:1", "3:4", "9:16") { + return nil, invalidPreparation("MiniMax H3 text-to-video requires a fixed supported ratio") + } + } + return map[string]any{"duration": duration, "resolution": resolution, "ratio": ratio}, nil + } if engine == "bailian" { if len(materials) < 1 || len(materials) > 2 { return nil, invalidPreparation("bailian video requires 1 or 2 image materials") diff --git a/backend/internal/jobs/provider_test.go b/backend/internal/jobs/provider_test.go index 0c17d6c..88c9200 100644 --- a/backend/internal/jobs/provider_test.go +++ b/backend/internal/jobs/provider_test.go @@ -245,6 +245,40 @@ func TestProviderJobBuilderPreparesBailianFirstAndLastFrameVideo(t *testing.T) { assertPreparedJob(t, cmd.Job, "bailian", "bailian-video-test", wantRequest) } +func TestProviderJobBuilderPreparesMinimaxH3TextAndFirstFrameVideo(t *testing.T) { + b := testProviderBuilder() + text, err := b.Build(context.Background(), "owner", "", "video.generate", "", map[string]any{ + "engine": "minimax", "prompt": "cinematic sunrise", + "settings": map[string]any{"duration": 6.0, "resolution": "2k", "ratio": "3:4"}, + }) + if err != nil { + t.Fatal(err) + } + var textRequest providers.Request + if err := json.Unmarshal(text.Job.RequestPayload, &textRequest); err != nil { + t.Fatal(err) + } + if text.Job.Provider != "minimax" || text.Job.ReqKey != providers.MinimaxH3Model || !reflect.DeepEqual(textRequest.Settings, map[string]any{"duration": float64(6), "resolution": "2K", "ratio": "3:4"}) { + t.Fatalf("job=%#v request=%#v", text.Job, textRequest) + } + + image, err := b.Build(context.Background(), "owner", "", "video.generate", "", map[string]any{ + "engine": "minimax", "prompt": "animate @图片1", + "materials": []any{map[string]any{"id": "first", "url": "https://in.test/first.png", "type": "image"}}, + "settings": map[string]any{"duration": 15.0, "resolution": "768p", "ratio": "16:9"}, + }) + if err != nil { + t.Fatal(err) + } + var imageRequest providers.Request + if err := json.Unmarshal(image.Job.RequestPayload, &imageRequest); err != nil { + t.Fatal(err) + } + if imageRequest.Settings["ratio"] != "adaptive" || imageRequest.Settings["resolution"] != "768P" || len(imageRequest.Materials) != 1 || imageRequest.Materials[0].Type != providers.MaterialImage { + t.Fatalf("request=%#v", imageRequest) + } +} + func TestProviderJobBuilderRejectsInvalidPreparation(t *testing.T) { tests := []struct { name, capability, message string @@ -265,6 +299,11 @@ func TestProviderJobBuilderRejectsInvalidPreparation(t *testing.T) { {name: "seedance 2.0 rejects 30 seconds", capability: "video.generate", body: map[string]any{"engine": "seedance", "model": seedance20Model, "prompt": "p", "settings": map[string]any{"duration": 30.0}}, message: "between 4 and 15"}, {name: "seedance 2.5 rejects over 30 seconds", capability: "video.generate", body: map[string]any{"engine": "seedance", "model": seedance25Model, "prompt": "p", "settings": map[string]any{"duration": 31.0}}, message: "between 4 and 30"}, {name: "seedance unsupported 4k output", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "settings": map[string]any{"resolution": "4k"}}, message: "unsupported video resolution"}, + {name: "minimax rejects adaptive text ratio", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "settings": map[string]any{"ratio": "adaptive"}}, message: "requires a fixed"}, + {name: "minimax rejects two images", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/1.png", "type": "image"}, map[string]any{"url": "https://in.test/2.png", "type": "image"}}}, message: "at most one"}, + {name: "minimax rejects video", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/1.mp4", "type": "video"}}}, message: "image material only"}, + {name: "minimax rejects short duration", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "settings": map[string]any{"duration": 3.0}}, message: "between 4 and 15"}, + {name: "minimax rejects resolution", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "settings": map[string]any{"resolution": "1080p"}}, message: "768P or 2K"}, {name: "seedance too many materials", capability: "video.generate", body: map[string]any{ "engine": "seedance", "prompt": "p", "materials": []any{ map[string]any{"url": "https://in.test/1.png", "type": "image"}, @@ -477,6 +516,59 @@ func TestProviderProcessorPersistsFriendlySeedreamOutputSafetyMessage(t *testing } } +func TestMinimaxSubmissionFailuresUseActionableSafeMessages(t *testing.T) { + tests := []struct { + status int + want string + }{ + {http.StatusUnauthorized, "鉴权失败"}, + {http.StatusForbidden, "调用权限"}, + {http.StatusPaymentRequired, "余额不足"}, + {http.StatusNotFound, "接口或模型不可用"}, + {http.StatusUnprocessableEntity, "内容安全审核"}, + {http.StatusTooManyRequests, "频率过高"}, + } + for _, test := range tests { + failure := providerSubmissionFailure(&providers.ProviderError{Operation: "minimax submit", Status: test.status, Code: "safe-code"}) + if failure.Retryable || !strings.Contains(failure.Message, test.want) || strings.Contains(failure.Message, "safe-code") { + t.Fatalf("status=%d failure=%#v", test.status, failure) + } + } +} + +func TestProviderProcessorPersistsMinimaxDiagnosticWithoutResubmission(t *testing.T) { + store := newMemoryJobStore() + job := Job{ + ID: "job-minimax-rejected", OwnerID: "owner", Provider: "minimax", ReqKey: providers.MinimaxH3Model, + Capability: "video.generate", Status: StatusQueued, + RequestPayload: json.RawMessage(`{"capability":"video.generate","model":"MiniMax-H3","prompt":"private prompt","settings":{"duration":4,"resolution":"768P","ratio":"1:1"}}`), + } + store.jobs[job.ID] = job + adapter := &countingProvider{err: &providers.ProviderError{ + Operation: "minimax submit", Status: 400, Code: "2013", RequestID: "request-minimax-rejected", + Detail: "invalid params, model not supported (2013)", + }} + processor := ProviderProcessor{Providers: ProviderRegistry{"minimax": adapter}, Store: store} + failed, err := processor.Advance(context.Background(), job) + if err != nil || failed.Status != StatusFailed || failed.Error == nil || failed.Error.Code != "2013" || failed.Error.Retryable { + t.Fatalf("failed=%#v err=%v", failed, err) + } + var response struct { + ProviderError struct { + Status int `json:"status"` + Code string `json:"code"` + RequestID string `json:"requestId"` + Detail string `json:"detail"` + } `json:"providerError"` + } + if err := json.Unmarshal(store.jobs[job.ID].ResponsePayload, &response); err != nil || response.ProviderError.RequestID != "request-minimax-rejected" || response.ProviderError.Code != "2013" || response.ProviderError.Status != 400 || response.ProviderError.Detail != adapter.err.(*providers.ProviderError).Detail { + t.Fatalf("persisted diagnostic=%#v err=%v", response, err) + } + if _, err := processor.Advance(context.Background(), store.jobs[job.ID]); err != nil || adapter.submits != 1 { + t.Fatalf("rejected task resubmitted: count=%d err=%v", adapter.submits, err) + } +} + func TestProviderProcessorClearsTransientErrorAfterSuccessfulPoll(t *testing.T) { store := newMemoryJobStore() job := Job{ID: "job-recovered", OwnerID: "owner", Provider: "fixture", ReqKey: "model-a", Capability: "image.generate", Status: StatusQueued, LockedBy: "worker", ProviderTaskID: "provider-task", Error: &JobError{Message: "temporary timeout", Retryable: true}, RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"model-a","prompt":"hello"}`)} @@ -644,6 +736,7 @@ func testProviderBuilder() ProviderJobBuilder { VideoEngines: map[string]ProviderTarget{ "seedance": {Provider: "seedance", Model: "seedance-test", Settings: map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}}, "bailian": {Provider: "bailian", Model: "bailian-video-test"}, + "minimax": {Provider: "minimax", Model: providers.MinimaxH3Model, Settings: map[string]any{"ratio": "16:9", "duration": 5, "resolution": "768P"}}, }, VideoModels: map[string]ProviderTarget{ seedance20Model: {Provider: "seedance", Model: seedance20Model, Settings: map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}}, diff --git a/backend/internal/orchestration/creation.go b/backend/internal/orchestration/creation.go index f690ead..117d408 100644 --- a/backend/internal/orchestration/creation.go +++ b/backend/internal/orchestration/creation.go @@ -306,7 +306,7 @@ func billingSQLState(err error) string { func retryEngine(provider, capability string) string { if capability == "video.generate" { - if provider == "seedance" || provider == "bailian" { + if provider == "seedance" || provider == "bailian" || provider == "minimax" { return provider } return "" diff --git a/backend/internal/orchestration/creation_test.go b/backend/internal/orchestration/creation_test.go index 419ef6c..8969c07 100644 --- a/backend/internal/orchestration/creation_test.go +++ b/backend/internal/orchestration/creation_test.go @@ -30,6 +30,12 @@ func TestCreationCoordinatorDoesNotFailCreationWhenAtomicCommitOutcomeIsUnknown( } } +func TestRetryEnginePreservesMinimaxVideoProvider(t *testing.T) { + if got := retryEngine("minimax", "video.generate"); got != "minimax" { + t.Fatalf("retry engine = %q, want minimax", got) + } +} + type commitUnknownCreationState struct { failCalls int } diff --git a/backend/internal/orchestration/settlement.go b/backend/internal/orchestration/settlement.go index 6d4db18..c9c0e8a 100644 --- a/backend/internal/orchestration/settlement.go +++ b/backend/internal/orchestration/settlement.go @@ -24,9 +24,9 @@ type FencedSettlementStateWriter interface { WriteBillingFenced(context.Context, string, json.RawMessage, jobs.Status, string) error } -// SettlementProcessor adds actual-usage settlement for Seedance videos and -// Seedream layer decomposition. Other providers and non-successful advances -// pass through. +// SettlementProcessor adds actual-usage settlement for Seedance and MiniMax +// videos plus Seedream layer decomposition. Other providers and unsuccessful +// advances pass through. type SettlementProcessor struct { inner jobs.Processor ledger SettlementLedger @@ -52,6 +52,9 @@ func (p *SettlementProcessor) Advance(ctx context.Context, job jobs.Job) (jobs.J if advanced.Provider == "seedream" && seedreamLayerRequest(advanced.RequestPayload) { return p.settleSeedreamLayers(ctx, advanced) } + if advanced.Provider == "minimax" && billing.IsMinimaxH3BillingModel(advanced.ReqKey) { + return p.settleMinimaxH3(ctx, advanced) + } if advanced.Provider != "seedance" { return advanced, nil } @@ -139,6 +142,85 @@ func (p *SettlementProcessor) Advance(ctx context.Context, job jobs.Job) (jobs.J return p.write(ctx, advanced, charge.raw) } +func (p *SettlementProcessor) settleMinimaxH3(ctx context.Context, job jobs.Job) (jobs.Job, error) { + var charge billingState + if json.Unmarshal(job.Billing, &charge) != nil { + return jobs.Job{}, errors.New("settle MiniMax H3 generation charge") + } + status, _ := charge.raw["settlementStatus"].(string) + if status == "settled" || status == "estimated" { + return job, nil + } + var use usageContext + if len(job.UsageContext) != 0 && json.Unmarshal(job.UsageContext, &use) != nil { + return jobs.Job{}, errors.New("settle MiniMax H3 generation charge") + } + quotaExempt := charge.QuotaExempt || use.Source == "platform" && use.Role == "super_admin" + chargeReady := charge.Status == "charged" || quotaExempt && charge.Status == "not_charged" + if !chargeReady || !quotaExempt && use.OrganizationID == "" { + return job, nil + } + + usage := minimaxH3Usage(job.ResponsePayload) + if usage.outputSeconds <= 0 { + charge.raw["settlementStatus"] = "estimated" + charge.raw["settlementReason"] = "provider_usage_unavailable" + charge.raw["settledAt"] = p.now().UTC().Format(time.RFC3339Nano) + return p.write(ctx, job, charge.raw) + } + resolution := minimaxResolution(charge.raw) + actualAmount, err := billing.CalculateMinimaxH3ActualAmountFen(billing.MinimaxH3ActualAmountInput{ + Resolution: resolution, OutputSeconds: usage.outputSeconds, InputVideoSeconds: usage.inputSeconds, + InputImageCount: usage.inputImageCount, MarkupMultiplier: numberOrZero(charge.raw["markupMultiplier"]), + }) + if err != nil { + return jobs.Job{}, errors.New("settle MiniMax H3 generation charge") + } + delta := actualAmount - charge.AmountFen + settledAt := p.now().UTC() + var settlementLedgerID string + if delta != 0 && !quotaExempt { + if p.ledger == nil { + return jobs.Job{}, errors.New("settle MiniMax H3 generation charge") + } + description := capabilityLabel(job.Capability) + "实际用量差额退回" + if delta > 0 { + description = capabilityLabel(job.Capability) + "实际用量补扣" + } + posting, postErr := p.ledger.Settle(ctx, billing.SettlementRequest{ + OrganizationID: use.OrganizationID, AccountID: use.AccountID, JobID: job.ID, + DeltaFen: delta, Description: description, + Metadata: map[string]any{ + "operation": "minimax_h3_actual_settlement", "reservedAmountFen": charge.AmountFen, + "actualAmountFen": actualAmount, "outputSeconds": usage.outputSeconds, + "inputVideoSeconds": usage.inputSeconds, "inputImageCount": usage.inputImageCount, + "resolution": resolution, "model": job.ReqKey, + }, + }) + if postErr != nil { + return jobs.Job{}, errors.New("settle MiniMax H3 generation charge") + } + settlementLedgerID = posting.LedgerID + if !posting.CreatedAt.IsZero() { + settledAt = posting.CreatedAt.UTC() + } + } + charge.raw["amountFen"] = actualAmount + charge.raw["settlementStatus"] = "settled" + charge.raw["settledAt"] = settledAt.Format(time.RFC3339Nano) + if settlementLedgerID != "" { + charge.raw["settlementLedgerEntryId"] = settlementLedgerID + } else { + delete(charge.raw, "settlementLedgerEntryId") + } + charge.raw["providerUsage"] = map[string]any{ + "model": job.ReqKey, "resolution": resolution, "outputSeconds": usage.outputSeconds, + "inputVideoSeconds": usage.inputSeconds, "inputImageCount": usage.inputImageCount, + "videoPriceFenPerSecond": billing.MinimaxH3VideoPriceFenPerSecond(resolution), + } + return p.write(ctx, job, charge.raw) +} + func (p *SettlementProcessor) settleSeedreamLayers(ctx context.Context, job jobs.Job) (jobs.Job, error) { var charge billingState if json.Unmarshal(job.Billing, &charge) != nil { @@ -242,6 +324,34 @@ func seedanceCompletionTokens(payload json.RawMessage) int64 { return value } +type minimaxUsage struct { + outputSeconds int64 + inputSeconds int64 + inputImageCount int64 +} + +func minimaxH3Usage(payload json.RawMessage) minimaxUsage { + var response struct { + Usage map[string]any `json:"usage"` + } + if json.Unmarshal(payload, &response) != nil { + return minimaxUsage{} + } + outputSeconds, _ := nonNegativeInteger(response.Usage["outputSeconds"]) + inputSeconds, _ := nonNegativeInteger(response.Usage["inputSeconds"]) + inputImageCount, _ := nonNegativeInteger(response.Usage["inputImageCount"]) + return minimaxUsage{outputSeconds: outputSeconds, inputSeconds: inputSeconds, inputImageCount: inputImageCount} +} + +func minimaxResolution(snapshot map[string]any) string { + parameters, _ := snapshot["parameters"].(map[string]any) + resolution, _ := parameters["resolution"].(string) + if strings.EqualFold(strings.TrimSpace(resolution), "2K") { + return "2K" + } + return "768P" +} + func seedreamLayerRequest(payload json.RawMessage) bool { var request struct { Settings map[string]any `json:"settings"` @@ -341,4 +451,12 @@ func integer(value any) (int64, bool) { return int64(number), true } +func nonNegativeInteger(value any) (int64, bool) { + number, ok := value.(float64) + if !ok || number < 0 || number != float64(int64(number)) { + return 0, false + } + return int64(number), true +} + var _ jobs.Processor = (*SettlementProcessor)(nil) diff --git a/backend/internal/orchestration/settlement_test.go b/backend/internal/orchestration/settlement_test.go index 8543128..91fb90e 100644 --- a/backend/internal/orchestration/settlement_test.go +++ b/backend/internal/orchestration/settlement_test.go @@ -102,6 +102,28 @@ func TestSettlementProcessorLeavesNonSeedanceSuccessAlone(t *testing.T) { } } +func TestSettlementProcessorSettlesSuccessfulMinimaxH3Usage(t *testing.T) { + state := &settlementStateStub{} + ledger := &settlementLedgerStub{posting: billing.WalletPosting{LedgerID: "minimax-settlement", CreatedAt: time.Date(2026, 9, 4, 8, 0, 0, 0, time.UTC)}} + advanced := jobs.Job{ + ID: "job-h3", Provider: "minimax", ReqKey: billing.MinimaxH3Model, Capability: "video.generate", Status: jobs.StatusSucceeded, + ResponsePayload: json.RawMessage(`{"usage":{"outputSeconds":6,"inputSeconds":0,"inputImageCount":1}}`), + Billing: json.RawMessage(`{"status":"charged","amountFen":300,"reservedAmountFen":300,"markupMultiplier":1.2,"settlementStatus":"pending","parameters":{"resolution":"768p"}}`), + UsageContext: json.RawMessage(`{"organizationId":"org-1","accountId":"account-1"}`), + } + processor := NewSettlementProcessor(settlementProcessorStub{job: advanced}, ledger, state, time.Now) + got, err := processor.Advance(context.Background(), jobs.Job{ID: "job-h3", Provider: "minimax", Status: jobs.StatusRunning}) + if err != nil || ledger.calls != 1 || ledger.request.DeltaFen != 60 || ledger.request.Metadata["operation"] != "minimax_h3_actual_settlement" { + t.Fatalf("job=%#v request=%#v calls=%d err=%v", got, ledger.request, ledger.calls, err) + } + var snapshot map[string]any + _ = json.Unmarshal(got.Billing, &snapshot) + usage := snapshot["providerUsage"].(map[string]any) + if snapshot["amountFen"] != float64(360) || snapshot["settlementStatus"] != "settled" || usage["outputSeconds"] != float64(6) || usage["videoPriceFenPerSecond"] != float64(50) { + t.Fatalf("billing snapshot=%#v", snapshot) + } +} + func TestSettlementProcessorRefundsUnusedSeedreamLayerReservation(t *testing.T) { state := &settlementStateStub{} ledger := &settlementLedgerStub{posting: billing.WalletPosting{LedgerID: "layer-settlement", CreatedAt: time.Date(2026, 8, 21, 8, 0, 0, 0, time.UTC)}} diff --git a/backend/internal/postgres/database.go b/backend/internal/postgres/database.go index e3f4948..0cb213c 100644 --- a/backend/internal/postgres/database.go +++ b/backend/internal/postgres/database.go @@ -57,6 +57,7 @@ SELECT AND constrained_table.relname = 'billing_price_rules' AND constraint_record.conname = 'billing_price_rules_provider_check' AND position('seedream' IN pg_get_constraintdef(constraint_record.oid)) > 0 + AND position('minimax' IN pg_get_constraintdef(constraint_record.oid)) > 0 ) AND has_function_privilege( current_user, diff --git a/backend/internal/postgres/database_test.go b/backend/internal/postgres/database_test.go index f056f17..fa30087 100644 --- a/backend/internal/postgres/database_test.go +++ b/backend/internal/postgres/database_test.go @@ -56,14 +56,15 @@ func TestReadinessSQLRequiresPublicGenerationJobLifecycleColumns(t *testing.T) { } } -func TestReadinessSQLRequiresSeedreamBillingProviderMigration(t *testing.T) { +func TestReadinessSQLRequiresBillingProviderMigrations(t *testing.T) { for _, fragment := range []string{ "billing_price_rules_provider_check", "pg_get_constraintdef", "position('seedream'", + "position('minimax'", } { if !strings.Contains(ReadinessSQL, fragment) { - t.Errorf("ReadinessSQL missing Seedream billing migration check %q", fragment) + t.Errorf("ReadinessSQL missing billing migration check %q", fragment) } } } diff --git a/backend/internal/providers/a4_test.go b/backend/internal/providers/a4_test.go new file mode 100644 index 0000000..2d38fde --- /dev/null +++ b/backend/internal/providers/a4_test.go @@ -0,0 +1,51 @@ +package providers + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +func TestA4ImageAdapterDimensions(t *testing.T) { + for _, size := range []struct { + width, height int + evoSize, bailianSize string + }{ + {1680, 2376, "848x1200", "1680*2376"}, + {2376, 1680, "1200x848", "2376*1680"}, + {848, 1200, "848x1200", "848*1200"}, + {1200, 848, "1200x848", "1200*848"}, + } { + request := Request{Capability: "image.generate", Prompt: "A4 itinerary", Settings: map[string]any{"width": size.width, "height": size.height}} + evo := NewEvoLink(Config{}, nil).payload(request).(map[string]any) + if evo["size"] != size.evoSize || evo["resolution"] != "1K" { + t.Fatalf("evolink=%#v", evo) + } + bailian := NewBailian(Config{}, nil).payload(request).(map[string]any) + if object(bailian["parameters"])["size"] != size.bailianSize { + t.Fatalf("bailian=%#v", bailian) + } + } +} + +func TestA4JimengDimensionsAreSubmittedUnchanged(t *testing.T) { + for _, size := range [][2]int{{1680, 2376}, {2376, 1680}} { + client := roundTripFunc(func(request *http.Request) (*http.Response, error) { + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["width"] != float64(size[0]) || body["height"] != float64(size[1]) { + t.Fatalf("body=%#v", body) + } + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"code":10000,"data":{"task_id":"test-a4"}}`))}, nil + }) + adapter := NewVolcengine(Config{BaseURL: "https://visual.test", Model: "jimeng_seedream46_cvtob", AccessKeyID: "test-key", SecretAccessKey: "test-secret"}, client, nil) + if _, err := adapter.Submit(context.Background(), Request{Capability: "image.generate", Prompt: "A4 itinerary", Settings: map[string]any{"width": size[0], "height": size[1]}}); err != nil { + t.Fatal(err) + } + } +} diff --git a/backend/internal/providers/adapters.go b/backend/internal/providers/adapters.go index 37fa28f..736a9b7 100644 --- a/backend/internal/providers/adapters.go +++ b/backend/internal/providers/adapters.go @@ -25,6 +25,13 @@ func NewEvoLink(c Config, client HTTPClient) *EvoLink { payload["size"] = size } else if width, widthOK := positiveInteger(r.Settings["width"]); widthOK { if height, heightOK := positiveInteger(r.Settings["height"]); heightOK { + // A4 custom pixels ignore the resolution shortcut. Match the + // frontend's 1K budget rather than silently requesting a 2K image. + if width == 1680 && height == 2376 { + width, height = 848, 1200 + } else if width == 2376 && height == 1680 { + width, height = 1200, 848 + } payload["size"] = supportedRatio(width, height) } } diff --git a/backend/internal/providers/minimax.go b/backend/internal/providers/minimax.go new file mode 100644 index 0000000..9abf982 --- /dev/null +++ b/backend/internal/providers/minimax.go @@ -0,0 +1,124 @@ +package providers + +import ( + "context" + "net/http" + "strings" +) + +const MinimaxH3Model = "MiniMax-H3" + +// Minimax adapts the MiniMax H3 V2 asynchronous video API to the common +// provider contract. The first release intentionally supports text-to-video +// and a single first-frame image only; request validation lives in jobs. +type Minimax struct{ *httpAdapter } + +func NewMinimax(c Config, client HTTPClient) *Minimax { + if client == nil { + client = http.DefaultClient + } + if strings.TrimSpace(c.Model) == "" { + c.Model = MinimaxH3Model + } + a := &httpAdapter{ + name: "minimax", + config: c, + client: client, + submitPath: func(Request) string { return "/v2/video_generation" }, + queryPath: func(id string) string { return "/v2/query/video_generation/" + id }, + payload: func(r Request) any { + content := []any{map[string]any{"type": "text", "text": r.Prompt}} + materials := r.Materials + if len(materials) == 0 && len(r.InputURLs) > 0 { + materials = []Material{{URL: r.InputURLs[0], Type: MaterialImage}} + } + if len(materials) > 0 { + content = append(content, map[string]any{ + "type": "image_url", + "image_url": map[string]any{"url": materials[0].URL}, + "role": "first_frame", + }) + } + return map[string]any{ + "model": requestModel(r, c.Model), + "content": content, + "resolution": r.Settings["resolution"], + "duration": r.Settings["duration"], + "ratio": r.Settings["ratio"], + "aigc_watermark": false, + } + }, + decode: decodeMinimax, + failureDetail: func(responseBody, requestBody []byte) string { + return minimaxSafeFailureDetail(responseBody, requestBody, c.APIKey) + }, + } + return &Minimax{a} +} + +func (a *Minimax) Submit(ctx context.Context, request Request) (Result, error) { + result, err := a.submit(ctx, request) + if err != nil { + logMinimaxFailedRequest(request, a.config, err) + } + return result, err +} + +func (a *Minimax) Query(ctx context.Context, id string) (Result, error) { + return a.query(ctx, id) +} + +func decodeMinimax(raw []byte) Result { + root := record(raw) + task := object(root["task"]) + if len(task) == 0 { + return Result{TaskID: stringValue(root["task_id"]), Status: StatusQueued} + } + outputURLs := []string{} + collectURLs(object(task["content"])["url"], &outputURLs) + usage := map[string]int{} + for source, target := range map[string]string{ + "total_seconds": "totalSeconds", + "input_seconds": "inputSeconds", + "output_seconds": "outputSeconds", + "input_image_count": "inputImageCount", + "input_audio_seconds": "inputAudioSeconds", + "total_tokens": "totalTokens", + "prompt_tokens": "promptTokens", + "completion_tokens": "completionTokens", + } { + if number, ok := object(task["usage"])[source].(float64); ok && number >= 0 { + usage[target] = int(number) + } + } + providerError := object(task["error"]) + resultStatus := status(task["status"]) + errorMessage := minimaxTaskFailureMessage(safeHTTPProviderDiagnosticToken(providerError["code"], 16), stringValue(providerError["message"])) + if resultStatus == StatusFailed && errorMessage == "" { + errorMessage = "MiniMax 视频生成失败,请调整提示词或素材后重试。" + } + return Result{ + TaskID: stringValue(task["id"], root["task_id"]), + Status: resultStatus, + OutputURLs: outputURLs, + ErrorMessage: errorMessage, + Usage: usage, + } +} + +func minimaxTaskFailureMessage(code, message string) string { + switch strings.TrimSpace(code) { + case "1008": + return "MiniMax API 账户余额不足,请充值后重试。" + case "1026": + return "视频描述或输入素材触发内容安全审核,请调整后重试。" + case "1002": + return "MiniMax 请求频率过高,请稍后重试。" + } + if strings.TrimSpace(message) != "" { + return "MiniMax 视频生成失败,请调整提示词或素材后重试。" + } + return "" +} + +var _ Adapter = (*Minimax)(nil) diff --git a/backend/internal/providers/minimax_diagnostics.go b/backend/internal/providers/minimax_diagnostics.go new file mode 100644 index 0000000..b55a1ed --- /dev/null +++ b/backend/internal/providers/minimax_diagnostics.go @@ -0,0 +1,114 @@ +package providers + +import ( + "encoding/json" + "errors" + "log" + "net/url" + "regexp" + "strconv" + "strings" + "unicode/utf8" +) + +var ( + minimaxDiagnosticURL = regexp.MustCompile(`(?i)(?:https?://|data:)[^\s"'<>]+`) + minimaxDiagnosticCredential = regexp.MustCompile(`(?i)(?:bearer\s+|(?:authorization|api[_-]?key|access[_-]?key|secret|token|password|signature|credential)\s*["']?\s*[:=]\s*["']?(?:bearer\s+)?)[^\s,"';}]+`) + minimaxDiagnosticQuoted = regexp.MustCompile("\"(?:[^\"\\\\]|\\\\.)*\"|'[^']*'|`[^`]*`") + minimaxDiagnosticLongToken = regexp.MustCompile(`[A-Za-z0-9_+/=.-]{32,}`) +) + +// Preserve the upstream validation explanation, but remove request content, +// credentials, URLs and arbitrary quoted values before it reaches logs or DB. +func minimaxSafeFailureDetail(responseBody, requestBody []byte, apiKey string) string { + root := record(responseBody) + message := stringValue(object(root["error"])["message"], root["message"]) + if message == "" { + return "" + } + privateValues := []string{apiKey} + request := record(requestBody) + content, _ := request["content"].([]any) + for _, item := range content { + entry := object(item) + privateValues = append(privateValues, stringValue(entry["text"])) + for _, kind := range []string{"image_url", "video_url", "audio_url"} { + privateValues = append(privateValues, stringValue(object(entry[kind])["url"])) + } + } + for _, value := range privateValues { + if value == "" { + continue + } + message = strings.ReplaceAll(message, value, "[redacted]") + encoded, _ := json.Marshal(value) + if len(encoded) > 2 { + message = strings.ReplaceAll(message, string(encoded[1:len(encoded)-1]), "[redacted]") + } + } + message = minimaxDiagnosticURL.ReplaceAllString(message, "[redacted]") + message = minimaxDiagnosticCredential.ReplaceAllString(message, "[redacted]") + message = minimaxDiagnosticQuoted.ReplaceAllStringFunc(message, func(quoted string) string { + value := quoted[1 : len(quoted)-1] + if minimaxDiagnosticValue(value) != "unrecognized" { + return quoted + } + return "[redacted]" + }) + message = minimaxDiagnosticLongToken.ReplaceAllString(message, "[redacted]") + message = strings.Join(strings.Fields(message), " ") + runes := []rune(message) + if len(runes) > 512 { + message = string(runes[:512]) + "...[truncated]" + } + return message +} + +func logMinimaxFailedRequest(request Request, config Config, err error) { + var failure *ProviderError + if !errors.As(err, &failure) { + return + } + // All strings except the request ID are fixed enums. In particular, never + // log custom endpoints, prompts or even the hosts of reference image URLs. + endpointClass := "custom" + basePathClass := "custom" + if endpoint, parseErr := url.Parse(config.BaseURL); parseErr == nil { + if endpoint.Hostname() == "api.minimax.cn" { + endpointClass = "official-cn" + } + switch strings.Trim(endpoint.Path, "/") { + case "": + basePathClass = "root" + case "v1", "v2": + basePathClass = "version-prefix" + } + } + imageItems := 0 + if len(request.Materials) > 0 || len(request.InputURLs) > 0 { + imageItems = 1 + } + duration := "invalid" + if encoded, encodeErr := json.Marshal(request.Settings["duration"]); encodeErr == nil { + if value, parseErr := strconv.Atoi(string(encoded)); parseErr == nil && value >= 4 && value <= 15 { + duration = strconv.Itoa(value) + } + } + log.Printf( + "zhinian-api MiniMax submit request summary requestId=%q model=%q resolution=%q duration=%s ratio=%q textItems=1 promptChars=%d imageItems=%d endpointClass=%s basePathClass=%s", + failure.RequestID, minimaxDiagnosticValue(requestModel(request, config.Model)), + minimaxDiagnosticValue(stringValue(request.Settings["resolution"])), duration, + minimaxDiagnosticValue(stringValue(request.Settings["ratio"])), utf8.RuneCountInString(request.Prompt), + imageItems, endpointClass, basePathClass, + ) +} + +func minimaxDiagnosticValue(value string) string { + switch value { + case MinimaxH3Model, "MiniMax-H3-Max", "768P", "2K", "480P", "adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16", + "model", "content", "text", "prompt", "resolution", "duration", "ratio", "image_url", "video_url", "audio_url", "role", "first_frame", "last_frame", "aigc_watermark": + return value + default: + return "unrecognized" + } +} diff --git a/backend/internal/providers/minimax_diagnostics_test.go b/backend/internal/providers/minimax_diagnostics_test.go new file mode 100644 index 0000000..3cbbaa0 --- /dev/null +++ b/backend/internal/providers/minimax_diagnostics_test.go @@ -0,0 +1,98 @@ +package providers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "reflect" + "strings" + "testing" + "unicode/utf8" +) + +func TestMinimaxPreservesRejectionReasonForPersistedTextVideoRequest(t *testing.T) { + var output bytes.Buffer + previousOutput := log.Writer() + log.SetOutput(&output) + t.Cleanup(func() { log.SetOutput(previousOutput) }) + + var request Request + if err := json.Unmarshal([]byte(`{"model":"MiniMax-H3","prompt":"一段盛开的烟花","settings":{"ratio":"1:1","duration":4,"resolution":"768P"},"capability":"video.generate"}`), &request); err != nil { + t.Fatal(err) + } + client := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost || r.URL.Path != "/v2/video_generation" { + t.Fatalf("unexpected endpoint: %s %s", r.Method, r.URL.Path) + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + want := map[string]any{ + "model": "MiniMax-H3", "resolution": "768P", "duration": float64(4), "ratio": "1:1", "aigc_watermark": false, + "content": []any{map[string]any{"type": "text", "text": "一段盛开的烟花"}}, + } + if !reflect.DeepEqual(payload, want) { + t.Fatalf("wire payload=%#v want=%#v", payload, want) + } + // A synthetic validation response: this does not assert that 4 seconds + // is unsupported by the live API, only that its explanation survives. + return &http.Response{StatusCode: 400, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"error":{"type":"bad_request_error","message":"invalid params, duration is not supported (2013)"},"request_id":"request-minimax-1"}`))}, nil + }) + adapter := NewMinimax(Config{BaseURL: "https://api.minimax.cn", APIKey: "private-api-key"}, client) + _, err := adapter.Submit(context.Background(), request) + var failure *ProviderError + if !errors.As(err, &failure) || failure.Code != "2013" || failure.RequestID != "request-minimax-1" || failure.Detail != "invalid params, duration is not supported (2013)" { + t.Fatalf("failure=%#v err=%v", failure, err) + } + if strings.Contains(err.Error(), "invalid params") { + t.Fatalf("generic error exposed diagnostic: %v", err) + } + for _, expected := range []string{`detail="invalid params, duration is not supported (2013)"`, `requestId="request-minimax-1"`, `model="MiniMax-H3"`, `resolution="768P"`, "duration=4", `ratio="1:1"`, "promptChars=7", "imageItems=0", "endpointClass=official-cn", "basePathClass=root"} { + if !strings.Contains(output.String(), expected) { + t.Errorf("missing %q from %s", expected, output.String()) + } + } + for _, private := range []string{"一段盛开的烟花", "private-api-key"} { + if strings.Contains(output.String(), private) { + t.Errorf("log leaked %q", private) + } + } +} + +func TestMinimaxFailureDetailRedactsEchoedInputsCredentialsAndURLs(t *testing.T) { + const prompt = "private prompt\nsecond line" + const imageURL = "https://private.test/a.png?Signature=private-signature" + request, _ := json.Marshal(map[string]any{"content": []any{ + map[string]any{"type": "text", "text": prompt}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": imageURL}}, + }}) + encodedPrompt, _ := json.Marshal(prompt) + response, _ := json.Marshal(map[string]any{"error": map[string]any{"message": "invalid params, 'model' \"MiniMax-H3\" not supported; " + prompt + " " + string(encodedPrompt) + " " + imageURL + " Bearer private-api-key api_key=another-key Authorization: Bearer unrelated-token 'private-user-name' https://other-private.test/a.png?token=other-secret (2013)"}}) + detail := minimaxSafeFailureDetail(response, request, "private-api-key") + for _, expected := range []string{"invalid params", "model", "MiniMax-H3", "not supported", "(2013)", "[redacted]"} { + if !strings.Contains(detail, expected) { + t.Fatalf("missing %q in %q", expected, detail) + } + } + for _, private := range []string{"private", "second line", "another-key", "unrelated-token", "other-secret", "Signature=", "https://", "Bearer", "\n"} { + if strings.Contains(detail, private) { + t.Fatalf("detail leaked %q: %s", private, detail) + } + } +} + +func TestMinimaxFailureDetailIsBoundedAndIgnoresOtherResponseFields(t *testing.T) { + response, _ := json.Marshal(map[string]any{"error": map[string]any{"message": strings.Repeat("参数无效 ", 200), "request": "private request body"}, "secret": "private secret"}) + detail := minimaxSafeFailureDetail(response, nil, "") + if utf8.RuneCountInString(detail) > 512+len("...[truncated]") || !strings.HasSuffix(detail, "...[truncated]") || strings.Contains(detail, "private") { + t.Fatalf("unsafe/unbounded detail: %s", detail) + } + if detail := minimaxSafeFailureDetail([]byte("not json"), nil, ""); detail != "" { + t.Fatalf("invalid JSON detail=%q", detail) + } +} diff --git a/backend/internal/providers/provider.go b/backend/internal/providers/provider.go index ef243b9..95a1df8 100644 --- a/backend/internal/providers/provider.go +++ b/backend/internal/providers/provider.go @@ -112,7 +112,11 @@ type ProviderError struct { Operation string Status int Code string - Cause error + RequestID string + // Detail is opt-in, bounded and redacted by the provider-specific adapter. + // Error() deliberately never includes it. + Detail string + Cause error } func (e *ProviderError) Error() string { @@ -125,14 +129,15 @@ func (e *ProviderError) Error() string { func (e *ProviderError) Unwrap() error { return e.Cause } type httpAdapter struct { - name string - config Config - client HTTPClient - submitPath func(Request) string - queryPath func(string) string - payload func(Request) any - headers func(*http.Request) - decode func([]byte) Result + name string + config Config + client HTTPClient + submitPath func(Request) string + queryPath func(string) string + payload func(Request) any + headers func(*http.Request) + decode func([]byte) Result + failureDetail func(responseBody, requestBody []byte) string } func (a *httpAdapter) submit(ctx context.Context, input Request) (Result, error) { @@ -206,6 +211,11 @@ func (a *httpAdapter) call(ctx context.Context, method, path string, body []byte } if resp.StatusCode < 200 || resp.StatusCode >= 300 { code, requestID, errorType := inspectHTTPProviderFailure(raw, resp.Header) + var detail string + // Only a submission carries the original content needed for redaction. + if operation == "submit" && a.failureDetail != nil { + detail = a.failureDetail(raw, body) + } logHTTPProviderFailure(httpProviderDiagnostic{ Provider: a.name, Operation: operation, @@ -215,8 +225,9 @@ func (a *httpAdapter) call(ctx context.Context, method, path string, body []byte ErrorType: errorType, ErrorClass: "service", ElapsedMS: elapsedMilliseconds(startedAt), + Detail: detail, }) - return Result{}, &ProviderError{Operation: a.name + " " + operation, Status: resp.StatusCode, Code: code} + return Result{}, &ProviderError{Operation: a.name + " " + operation, Status: resp.StatusCode, Code: code, RequestID: requestID, Detail: detail} } if !json.Valid(raw) { logHTTPProviderFailure(httpProviderDiagnostic{ @@ -238,9 +249,18 @@ type httpProviderDiagnostic struct { ErrorType string ErrorClass string ElapsedMS int64 + Detail string } func logHTTPProviderFailure(diagnostic httpProviderDiagnostic) { + if diagnostic.Detail != "" { + log.Printf( + "zhinian-api provider operation failed provider=%s operation=%s status=%d code=%q requestId=%q errorType=%q errorClass=%s elapsedMs=%d detail=%q", + diagnostic.Provider, diagnostic.Operation, diagnostic.Status, diagnostic.Code, diagnostic.RequestID, + diagnostic.ErrorType, diagnostic.ErrorClass, diagnostic.ElapsedMS, diagnostic.Detail, + ) + return + } log.Printf( "zhinian-api provider operation failed provider=%s operation=%s status=%d code=%q requestId=%q errorType=%q errorClass=%s elapsedMs=%d", diagnostic.Provider, @@ -269,6 +289,9 @@ func inspectHTTPProviderFailure(raw []byte, headers http.Header) (code, requestI metadataError["code"], metadataError["Code"], root["code"], root["Code"], ), 64) + if code == "" { + code = parenthesizedNumericCode(stringValue(providerError["message"], root["message"])) + } requestID = safeHTTPProviderDiagnosticToken(first( providerError["request_id"], providerError["requestId"], providerError["RequestId"], providerError["RequestID"], root["request_id"], root["requestId"], root["RequestId"], root["RequestID"], @@ -283,6 +306,24 @@ func inspectHTTPProviderFailure(raw []byte, headers http.Header) (code, requestI return code, requestID, errorType } +func parenthesizedNumericCode(message string) string { + message = strings.TrimSpace(message) + if len(message) < 3 || message[len(message)-1] != ')' { + return "" + } + start := strings.LastIndexByte(message, '(') + if start < 0 || start == len(message)-2 { + return "" + } + code := message[start+1 : len(message)-1] + for _, character := range code { + if character < '0' || character > '9' { + return "" + } + } + return safeHTTPProviderDiagnosticToken(code, 16) +} + func safeHTTPProviderDiagnosticToken(value any, maxLength int) string { var token string switch typed := value.(type) { diff --git a/backend/internal/providers/providers_test.go b/backend/internal/providers/providers_test.go index 0b5c44d..5657e7d 100644 --- a/backend/internal/providers/providers_test.go +++ b/backend/internal/providers/providers_test.go @@ -27,6 +27,7 @@ func TestHTTPAdaptersMapRequestsAndResponses(t *testing.T) { {"evolink", NewEvoLink(Config{BaseURL: "https://e.test", APIKey: "secret", Model: "gpt-image-2"}, nil), "/v1/images/generations", "/v1/tasks/task-1", `{"id":"task-1","status":"completed","results":[{"url":"https://cdn.test/a.png"}]}`}, {"bailian", NewBailian(Config{BaseURL: "https://b.test", APIKey: "secret", Model: "wan"}, nil), "/api/v1/services/aigc/image-generation/generation", "/api/v1/tasks/task-1", `{"output":{"task_id":"task-1","task_status":"SUCCEEDED","results":[{"url":"https://cdn.test/a.png"}]}}`}, {"seedance", NewSeedance(Config{BaseURL: "https://s.test/api/v3", APIKey: "secret", Model: "seed"}, nil), "/api/v3/contents/generations/tasks", "/api/v3/contents/generations/tasks/task-1", `{"id":"task-1","status":"succeeded","content":{"video_url":"https://cdn.test/a.mp4"}}`}, + {"minimax", NewMinimax(Config{BaseURL: "https://m.test", APIKey: "secret", Model: MinimaxH3Model}, nil), "/v2/video_generation", "/v2/query/video_generation/task-1", `{"task":{"id":"task-1","status":"succeeded","content":{"url":"https://cdn.test/a.mp4"}}}`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -52,6 +53,8 @@ func TestHTTPAdaptersMapRequestsAndResponses(t *testing.T) { a.client = client case *Seedance: a.client = client + case *Minimax: + a.client = client } submitted, err := tt.adapter.Submit(context.Background(), Request{Capability: "image.generate", Prompt: "hello"}) if err != nil || submitted.TaskID != "task-1" { @@ -65,6 +68,50 @@ func TestHTTPAdaptersMapRequestsAndResponses(t *testing.T) { } } +func TestMinimaxH3PayloadAndUsageMatchOfficialV2Contract(t *testing.T) { + var calls int + client := roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls++ + if request.Header.Get("Authorization") != "Bearer secret" { + t.Fatalf("authorization=%q", request.Header.Get("Authorization")) + } + if calls == 1 { + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + content := body["content"].([]any) + image := content[1].(map[string]any) + imageURL := image["image_url"].(map[string]any) + if body["model"] != MinimaxH3Model || body["resolution"] != "2K" || body["duration"] != float64(8) || body["ratio"] != "adaptive" || body["aigc_watermark"] != false || len(content) != 2 || image["role"] != "first_frame" || imageURL["url"] != "https://cdn.test/first.png" { + t.Fatalf("body=%#v", body) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"task_id":"task-h3"}`)), Header: http.Header{}}, nil + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"task":{"id":"task-h3","model":"MiniMax-H3","status":"succeeded","content":{"url":"https://cdn.test/h3.mp4"},"resolution":"2K","duration":8,"usage":{"total_seconds":8,"input_seconds":0,"output_seconds":8,"input_image_count":1,"completion_tokens":1234},"ratio":"16:9"}}`)), Header: http.Header{}}, nil + }) + adapter := NewMinimax(Config{BaseURL: "https://m.test", APIKey: "secret", Model: MinimaxH3Model}, client) + submitted, err := adapter.Submit(context.Background(), Request{ + Capability: "video.generate", Prompt: "animate", Materials: []Material{{URL: "https://cdn.test/first.png", Type: MaterialImage}}, + Settings: map[string]any{"resolution": "2K", "duration": float64(8), "ratio": "adaptive"}, + }) + if err != nil || submitted.TaskID != "task-h3" || submitted.Status != StatusQueued { + t.Fatalf("submitted=%#v err=%v", submitted, err) + } + queried, err := adapter.Query(context.Background(), submitted.TaskID) + if err != nil || queried.Status != StatusSucceeded || !reflect.DeepEqual(queried.OutputURLs, []string{"https://cdn.test/h3.mp4"}) || queried.Usage["outputSeconds"] != 8 || queried.Usage["inputImageCount"] != 1 { + t.Fatalf("queried=%#v err=%v", queried, err) + } +} + +func TestMinimaxErrorDiagnosticsExtractSafeNumericCode(t *testing.T) { + raw := []byte(`{"type":"error","error":{"type":"insufficient_balance_error","message":"insufficient balance (1008)","http_code":"402"},"request_id":"request-safe"}`) + code, requestID, errorType := inspectHTTPProviderFailure(raw, http.Header{}) + if code != "1008" || requestID != "request-safe" || errorType != "insufficient_balance_error" { + t.Fatalf("diagnostics=%q/%q/%q", code, requestID, errorType) + } +} + func TestBailianOnlyEnablesAsyncModeWhenSubmitting(t *testing.T) { var requests []*http.Request client := roundTripFunc(func(request *http.Request) (*http.Response, error) { diff --git a/backend/internal/settings/service.go b/backend/internal/settings/service.go index 94f8999..b59a568 100644 --- a/backend/internal/settings/service.go +++ b/backend/internal/settings/service.go @@ -10,6 +10,8 @@ import ( "sort" "strings" "sync" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/providers" ) type RuntimeUpdater func(context.Context, map[string]string) error @@ -68,6 +70,7 @@ type Services struct { Seedance bool `json:"seedance"` Seedream bool `json:"seedream"` Bailian bool `json:"bailian"` + Minimax bool `json:"minimax"` Auth bool `json:"auth"` Organization bool `json:"organization"` } @@ -321,13 +324,15 @@ func (s *Service) get() (Payload, error) { videoModel := lookup(s.database, s.environment, file, "SEEDANCE_MODEL", "doubao-seedance-2-0-260128") if video == "bailian" { videoModel = lookup(s.database, s.environment, file, "BAILIAN_VIDEO_MODEL", "wan2.7-i2v-2026-04-25") + } else if video == "minimax" { + videoModel = providers.MinimaxH3Model } imageConnected := connected(image, s.database, s.environment, file) videoConnected := connected(video, s.database, s.environment, file) imageField := project(fieldIndex["IMAGE_GENERATE_ENGINE"], image, s.database, s.environment, file) videoField := project(fieldIndex["VIDEO_GENERATE_ENGINE"], video, s.database, s.environment, file) assignments := []EngineAssignment{{ID: "image.generate", Label: "图片生成", Engine: image, EngineLabel: label(image), Connected: imageConnected, ConnectionLabel: connection(imageConnected), ReqKey: imageModel, Configurable: true, Field: &imageField}, {ID: "video.generate", Label: "视频生成", Engine: video, EngineLabel: label(video), Connected: videoConnected, ConnectionLabel: connection(videoConnected), ReqKey: videoModel, Configurable: true, Field: &videoField}} - services := Services{Visual: connected("jimeng", s.database, s.environment, file), Evolink: connected("evolink", s.database, s.environment, file), Seedance: connected("seedance", s.database, s.environment, file), Seedream: connected("seedream", s.database, s.environment, file), Bailian: connected("bailian", s.database, s.environment, file), Auth: lookup(s.database, s.environment, file, "ZHINIAN_AUTH_SESSION_SECRET", "") != "", Organization: lookup(s.database, s.environment, file, "DATABASE_URL", "") != "" || lookup(s.database, s.environment, file, "ZHINIAN_AUTH_SESSION_SECRET", "") != ""} + services := Services{Visual: connected("jimeng", s.database, s.environment, file), Evolink: connected("evolink", s.database, s.environment, file), Seedance: connected("seedance", s.database, s.environment, file), Seedream: connected("seedream", s.database, s.environment, file), Bailian: connected("bailian", s.database, s.environment, file), Minimax: connected("minimax", s.database, s.environment, file), Auth: lookup(s.database, s.environment, file, "ZHINIAN_AUTH_SESSION_SECRET", "") != "", Organization: lookup(s.database, s.environment, file, "DATABASE_URL", "") != "" || lookup(s.database, s.environment, file, "ZHINIAN_AUTH_SESSION_SECRET", "") != ""} imageLabel := "图片生成 4.6" if image == "seedream" { imageLabel = "Seedream 5.0 Pro 图片生成" @@ -546,10 +551,14 @@ func normalizeImage(value string) string { return "jimeng" } func normalizeVideo(value string) string { - if strings.ToLower(strings.TrimSpace(value)) == "bailian" { + switch strings.ToLower(strings.TrimSpace(value)) { + case "bailian": return "bailian" + case "minimax": + return "minimax" + default: + return "seedance" } - return "seedance" } func label(engine string) string { switch engine { @@ -561,6 +570,8 @@ func label(engine string) string { return "Seedance" case "seedream": return "Seedream 5.0 Pro" + case "minimax": + return "MiniMax H3" default: return "即梦" } @@ -579,6 +590,8 @@ func connected(engine string, database, environment, file map[string]string) boo return strings.TrimSpace(lookup(database, environment, file, "SEEDANCE_API_KEY", "")) != "" case "bailian": return strings.TrimSpace(lookup(database, environment, file, "BAILIAN_API_KEY", "")) != "" || strings.TrimSpace(lookup(database, environment, file, "DASHSCOPE_API_KEY", "")) != "" + case "minimax": + return strings.TrimSpace(lookup(database, environment, file, "MINIMAX_API_KEY", "")) != "" default: return strings.TrimSpace(lookup(database, environment, file, "VOLCENGINE_ACCESS_KEY_ID", "")) != "" && strings.TrimSpace(lookup(database, environment, file, "VOLCENGINE_SECRET_ACCESS_KEY", "")) != "" } @@ -592,6 +605,7 @@ func definitions() []Group { {ID: "evolink", Title: "EvoLink 图片 API", Description: "GPT Image 2 图片生成。", Fields: []Field{{Key: "EVOLINK_API_KEY", Label: "EvoLink API Key", Secret: true, Type: "password"}, {Key: "EVOLINK_BASE_URL", Label: "Base URL", DefaultValue: "https://api.evolink.ai"}, {Key: "EVOLINK_IMAGE_MODEL", Label: "图片模型", DefaultValue: "gpt-image-2"}, {Key: "EVOLINK_IMAGE_QUALITY", Label: "质量", DefaultValue: "medium"}}}, {ID: "seedance", Title: "火山方舟 API", Description: "Seedance 视频与 Seedream 5.0 Pro 图片共用此 API Key。", Fields: []Field{{Key: "SEEDANCE_API_KEY", Label: "方舟 API Key", Secret: true, Type: "password"}}}, {ID: "bailian", Title: "阿里云百炼 API", Description: "万相图片与视频。", Fields: []Field{{Key: "BAILIAN_API_KEY", Label: "百炼 API Key", Secret: true, Type: "password"}, {Key: "BAILIAN_BASE_URL", Label: "Base URL", DefaultValue: "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"}, {Key: "BAILIAN_IMAGE_MODEL", Label: "图片模型", DefaultValue: "wan2.7-image-pro"}, {Key: "BAILIAN_VIDEO_MODEL", Label: "视频模型", DefaultValue: "wan2.7-i2v-2026-04-25"}}}, + {ID: "minimax", Title: "MiniMax API", Description: "MiniMax H3 视频生成。", Fields: []Field{{Key: "MINIMAX_API_KEY", Label: "MiniMax API Key", Secret: true, Type: "password"}, {Key: "MINIMAX_BASE_URL", Label: "Base URL", DefaultValue: "https://api.minimax.cn"}}}, {ID: "oss", Title: "OSS 资产存储", Description: "私有桶通过后端签名访问。", Fields: []Field{{Key: "ALI_OSS_ENDPOINT", Label: "Endpoint"}, {Key: "ALI_OSS_BUCKET", Label: "Bucket"}, {Key: "ALI_OSS_ACCESS_KEY_ID", Label: "Access Key ID", Secret: true, Type: "password"}, {Key: "ALI_OSS_ACCESS_KEY_SECRET", Label: "Access Key Secret", Secret: true, Type: "password"}, {Key: "ALI_OSS_PUBLIC_BASE_URL", Label: "Bucket Base URL(无需公共读)"}}}, } } @@ -604,7 +618,7 @@ var fieldIndex = func() map[string]Field { } } result["IMAGE_GENERATE_ENGINE"] = Field{Key: "IMAGE_GENERATE_ENGINE", Label: "图片生成", Type: "select", DefaultValue: "jimeng", Options: []Option{{Label: "即梦 4.6 / 火山视觉", Value: "jimeng"}, {Label: "Seedream 5.0 Pro / 火山方舟", Value: "seedream"}, {Label: "EvoLink GPT Image 2", Value: "evolink"}, {Label: "阿里云百炼 Wan 2.7", Value: "bailian"}}} - result["VIDEO_GENERATE_ENGINE"] = Field{Key: "VIDEO_GENERATE_ENGINE", Label: "视频生成", Type: "select", DefaultValue: "bailian", Options: []Option{{Label: "Seedance", Value: "seedance"}, {Label: "阿里云百炼 Wan 2.7", Value: "bailian"}}} + result["VIDEO_GENERATE_ENGINE"] = Field{Key: "VIDEO_GENERATE_ENGINE", Label: "视频生成", Type: "select", DefaultValue: "bailian", Options: []Option{{Label: "Seedance", Value: "seedance"}, {Label: "MiniMax H3", Value: "minimax"}, {Label: "阿里云百炼 Wan 2.7", Value: "bailian"}}} return result }() diff --git a/backend/internal/settings/service_test.go b/backend/internal/settings/service_test.go index e850c19..79107fd 100644 --- a/backend/internal/settings/service_test.go +++ b/backend/internal/settings/service_test.go @@ -10,6 +10,8 @@ import ( "sort" "strings" "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/providers" ) func TestFixtureIsConsumedByGo(t *testing.T) { @@ -27,7 +29,7 @@ func TestFixtureIsConsumedByGo(t *testing.T) { if err := json.Unmarshal(data, &fixture); err != nil { t.Fatal(err) } - if fixture.Version != 1 || fixture.FileName != ".env.local" || len(fixture.AllowedKeys) != 25 || len(fixture.SecretKeys) != 8 { + if fixture.Version != 1 || fixture.FileName != ".env.local" || len(fixture.AllowedKeys) != 27 || len(fixture.SecretKeys) != 9 { t.Fatalf("fixture = %#v", fixture) } wantDatabaseKeys := RuntimeSettingKeys() @@ -38,6 +40,23 @@ func TestFixtureIsConsumedByGo(t *testing.T) { } } +func TestMinimaxSettingsAreDatabaseBackedAndReportedConnected(t *testing.T) { + repository := &runtimeSettingsRepositoryStub{values: map[string]string{ + "MINIMAX_API_KEY": "secret", + "MINIMAX_BASE_URL": "https://api.minimax.test", + "VIDEO_GENERATE_ENGINE": "minimax", + }} + service := NewWithRuntimeRepository(filepath.Join(t.TempDir(), ".env.local"), nil, repository, nil) + value, err := service.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + payload := value.(Payload) + if !payload.Services.Minimax || payload.EngineAssignments[1].Engine != "minimax" || payload.EngineAssignments[1].ReqKey != providers.MinimaxH3Model { + t.Fatalf("payload=%#v", payload) + } +} + func TestServiceSavePreservesSecretsAndUnrelatedEnvLines(t *testing.T) { path := filepath.Join(t.TempDir(), ".env.local") original := "# operator note\nDATABASE_URL=postgres://untouched\nZHINIAN_AUTH_SESSION_SECRET=keep-me\nZHINIAN_BILLING_CONTACT=old # prior\nCUSTOM_QUOTED=\"a b\"\n" diff --git a/backend/internal/usage/report.go b/backend/internal/usage/report.go index 595136c..8cb3dab 100644 --- a/backend/internal/usage/report.go +++ b/backend/internal/usage/report.go @@ -392,8 +392,12 @@ func providerLabel(value string) string { return "EvoLink" case "seedance": return "Seedance" + case "seedream": + return "Seedream 5.0 Pro" case "bailian": return "阿里云百炼" + case "minimax": + return "MiniMax H3" case "mock": return "系统" default: diff --git a/components/asset-manager.tsx b/components/asset-manager.tsx index df9a71b..69c9c90 100644 --- a/components/asset-manager.tsx +++ b/components/asset-manager.tsx @@ -490,6 +490,7 @@ function providerLabel(provider: GenerationJob["provider"]) { if (provider === "seedance") return "Seedance"; if (provider === "seedream") return "Seedream 5.0 Pro"; if (provider === "bailian") return "阿里云百炼"; + if (provider === "minimax") return "MiniMax H3"; return "系统生成"; } diff --git a/components/billing-manager.tsx b/components/billing-manager.tsx index 2cf62d5..13264b6 100644 --- a/components/billing-manager.tsx +++ b/components/billing-manager.tsx @@ -763,6 +763,7 @@ function providerName(provider: string) { if (provider === "seedance") return "Seedance"; if (provider === "seedream") return "Seedream 5.0 Pro"; if (provider === "bailian") return "百炼"; + if (provider === "minimax") return "MiniMax H3"; return provider; } diff --git a/components/create-studio.tsx b/components/create-studio.tsx index 1bd1b4b..fb2b341 100644 --- a/components/create-studio.tsx +++ b/components/create-studio.tsx @@ -5,6 +5,7 @@ import { Check, CircleDollarSign, Download, Film, ImageIcon, ImagePlus, Info, La import clsx from "clsx"; import { clampPage, pageItems, Pagination } from "@/components/pagination"; import { SeedreamWorkspace } from "@/components/seedream-workspace"; +import { imageSizePresets, imageDimensionsForEngine, imageSizePresetFromDimensions, type ImageSizePreset } from "@/lib/image-size-presets"; import { SeedreamLayerResultViewer, type SeedreamLayerOutput } from "@/components/seedream-layer-result-viewer"; import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import { @@ -46,7 +47,7 @@ import type { BillingQuote } from "@/lib/types"; type GenerateMode = "image" | "video"; type MaterialKind = PromptMaterial["type"]; type ImageGenerateEngine = "jimeng" | "evolink" | "bailian" | "seedream"; -type VideoGenerateEngine = "seedance" | "bailian"; +type VideoGenerateEngine = "seedance" | "bailian" | "minimax"; type MentionState = { start: number; @@ -133,16 +134,6 @@ const seedreamOptimizeModeOptions = [ { id: "fast", label: "快速优化" } ] as const; -const imageSizePresets = [ - { label: "1:1", width: 2048, height: 2048 }, - { label: "4:3", width: 2304, height: 1728 }, - { label: "3:4", width: 1728, height: 2304 }, - { label: "16:9", width: 2560, height: 1440 }, - { label: "9:16", width: 1440, height: 2560 } -]; - -type ImageSizePreset = (typeof imageSizePresets)[number]; - type TemplateConsoleSnapshot = { promptByMode: Record; imageSize: ImageSizePreset; @@ -155,6 +146,8 @@ type TemplateConsoleSnapshot = { }; const materialUploadAccept = "image/*,video/*,audio/*"; +const minimaxVideoRatios = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"] as const; +const minimaxVideoResolutions = ["768P", "2K"] as const; const templatePreviewAccept = "image/*"; const MATERIAL_PAGE_SIZE = 6; const defaultTemplateForm: TemplateForm = { @@ -273,6 +266,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate [seedreamAnnotations, selectedSeedreamSourceKeys] ); const specializedImageMode = generateMode === "image" && imageCreationMode !== "basic"; + const minimaxVideoMode = generateMode === "video" && videoEngine === "minimax"; + const minimaxFirstFrameSelected = minimaxVideoMode && materials.length === 1 && materials[0]?.type === "image"; + const minimaxMaterialsValid = !minimaxVideoMode || materials.length <= 1 && materials.every((material) => material.type === "image"); const unsupportedSpecializedPlaceholders = useMemo( () => specializedImageMode ? materialPlaceholders.filter((placeholder) => ( placeholder.type !== "image" @@ -304,9 +300,12 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate && !annotationRequired && unsupportedSpecializedPlaceholders.length === 0 && missingMaterialPlaceholders.length === 0 - && missingAnnotationPlaceholders.length === 0; + && missingAnnotationPlaceholders.length === 0 + && minimaxMaterialsValid; const submitDisabled = busy || !generationInputReady; - const submitTitle = unsupportedSpecializedPlaceholders.length + const submitTitle = !minimaxMaterialsValid + ? "MiniMax H3 第一版仅支持 0 或 1 张图片素材" + : unsupportedSpecializedPlaceholders.length ? `交互编辑和图层拆分不支持 ${unsupportedSpecializedPlaceholders.map((placeholder) => placeholder.token).join("、")}` : missingMaterialPlaceholders.length ? `请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}` @@ -401,12 +400,15 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate if (active && (imageCapability?.engine === "evolink" || imageCapability?.engine === "jimeng" || imageCapability?.engine === "bailian" || imageCapability?.engine === "seedream")) { setImageEngine(imageCapability.engine); } - if (active && (videoCapability?.engine === "seedance" || videoCapability?.engine === "bailian")) { + if (active && (videoCapability?.engine === "seedance" || videoCapability?.engine === "bailian" || videoCapability?.engine === "minimax")) { setVideoEngine(videoCapability.engine); if (videoCapability.engine === "seedance") { const nextModel = normalizeSeedanceModel(videoCapability.reqKey); setVideoModel(nextModel); setVideoDuration((duration) => clampVideoDuration(duration, VIDEO_DURATION_DEFAULT, { allowAuto: false, model: nextModel })); + } else if (videoCapability.engine === "minimax") { + setVideoDuration((duration) => Math.min(15, Math.max(4, duration))); + setVideoResolution("768P"); } } }) @@ -631,14 +633,16 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate return payload.assets as Asset[]; } - async function uploadFiles(files: FileList | null, options: { targetLabel?: string; expectedType?: MaterialKind; seedreamSourceMode?: "single" | "append" } = {}) { + async function uploadFiles(files: FileList | null, options: { targetLabel?: string; expectedType?: MaterialKind; seedreamSourceMode?: "single" | "append"; maxFiles?: number; replaceAll?: boolean } = {}) { const allFiles = Array.from(files || []); const remainingInteractiveSlots = Math.max(0, 10 - seedreamInteractiveSourceKeys.length); const selectedFiles = options.seedreamSourceMode === "single" ? allFiles.slice(0, 1) : options.seedreamSourceMode === "append" ? allFiles.slice(0, remainingInteractiveSlots) - : allFiles; + : options.maxFiles + ? allFiles.slice(0, options.maxFiles) + : allFiles; const expectedType = options.expectedType; if (!selectedFiles.length) { if (allFiles.length && options.seedreamSourceMode === "append") setError("Seedream 5.0 Pro 交互编辑最多支持 10 张输入图片。"); @@ -661,7 +665,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate throw new Error(`请上传${shortTypeName(expectedType)}素材。`); } setMaterials((items) => { - const next = [...items]; + const next = options.replaceAll ? [] : [...items]; uploaded.forEach((asset, index) => { const targetLabel = index === 0 ? normalizeMaterialLabel(options.targetLabel) : undefined; const material = materialFromAsset(asset, next, targetLabel); @@ -818,7 +822,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate if (generateMode === "image") { const templateEngine = normalizeImageEngine(template.settings.engine); if (templateEngine) setImageEngine(imageCreationMode === "basic" ? templateEngine : "seedream"); - const preset = imageSizePresets.find((item) => item.width === template.settings.width && item.height === template.settings.height); + const preset = imageSizePresetFromDimensions(template.settings.width, template.settings.height); if (preset) setImageSize(preset); const templateInfluence = jimengInfluenceOptions.find((option) => option.scale === template.settings.scale); if (templateInfluence) setJimengInfluence(templateInfluence.id); @@ -1063,8 +1067,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate engine: imageEngine, prompt, materials: materials.filter((material) => material.type === "image"), - width: imageSize.width, - height: imageSize.height, + ...imageDimensionsForEngine(imageSize, imageEngine), ...(imageEngine === "evolink" ? { quality: selectedEvolinkQuality.quality } : { scale: selectedJimengInfluence.scale }), @@ -1079,9 +1082,11 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate prompt, materials, settings: { - ...(videoEngine === "seedance" ? { ratio: videoRatio } : {}), + ...(videoEngine === "seedance" || videoEngine === "minimax" + ? { ratio: videoEngine === "minimax" && minimaxFirstFrameSelected ? "adaptive" : videoRatio } + : {}), duration: videoDuration, - resolution: videoEngine === "bailian" ? videoResolution.toUpperCase() : videoResolution + resolution: videoEngine === "bailian" || videoEngine === "minimax" ? videoResolution.toUpperCase() : videoResolution } }; } @@ -1136,6 +1141,11 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate throw new Error("百炼图生视频请上传 1 张首帧图,或按顺序上传 2 张首尾帧图。"); } } + if (generateMode === "video" && videoEngine === "minimax") { + if (materials.length > 1 || materials.some((material) => material.type !== "image")) { + throw new Error("MiniMax H3 第一版支持纯文字生成,或只上传 1 张图片作为首帧。"); + } + } const markedMaterials = generateMode === "image" && imageCreationMode !== "basic" ? await prepareMarkedSeedreamMaterials() : undefined; @@ -1320,13 +1330,15 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate {uploading ? : } { - void uploadFiles(event.target.files, specializedImageMode ? { - expectedType: "image", - seedreamSourceMode: imageCreationMode === "interactive" ? "append" : "single" - } : {}); + void uploadFiles(event.target.files, minimaxVideoMode + ? { expectedType: "image", maxFiles: 1, replaceAll: true } + : specializedImageMode ? { + expectedType: "image", + seedreamSourceMode: imageCreationMode === "interactive" ? "append" : "single" + } : {}); event.currentTarget.value = ""; }} /> @@ -1497,6 +1509,11 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate {imageSizePresets.map((preset) => )} )} + {imageEngine !== "seedream" && imageSize.paper === "a4" ? ( + + A4 比例 · {imageDimensionsForEngine(imageSize, imageEngine).width} × {imageDimensionsForEngine(imageSize, imageEngine).height} px + + ) : null}
@@ -1530,13 +1547,20 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
@@ -1552,11 +1576,11 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
) : null} - {videoEngine === "seedance" ? ( + {videoEngine === "seedance" || videoEngine === "minimax" ? (
- setVideoRatio(event.target.value)}> + {(videoEngine === "minimax" ? minimaxFirstFrameSelected ? ["adaptive"] : minimaxVideoRatios : VIDEO_RATIOS).map((ratio) => ( ))} @@ -1567,15 +1591,21 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
@@ -2301,6 +2331,7 @@ function taskEngineLabel(value: string) { if (value === "evolink") return "Image2"; if (value === "bailian") return "阿里云百炼"; if (value === "seedance") return "Seedance"; + if (value === "minimax") return "MiniMax H3"; if (value === "mock") return "历史任务"; return value; } @@ -2370,6 +2401,7 @@ function providerLabel(provider: GenerationJob["provider"]) { if (provider === "seedance") return "Seedance"; if (provider === "seedream") return "Seedream 5.0 Pro"; if (provider === "bailian") return "阿里云百炼"; + if (provider === "minimax") return "MiniMax H3"; return "系统生成"; } @@ -2458,7 +2490,7 @@ function templatePayloadFromForm(form: TemplateForm) { forceSingle: true, ...(form.engine === "seedream" ? { size: form.seedreamSize, outputFormat: form.seedreamOutputFormat, optimizeMode: form.seedreamOptimizeMode } - : { width: size.width, height: size.height }), + : imageDimensionsForEngine(size, form.engine)), ...(form.engine === "evolink" ? { quality: selectedQuality.quality } : form.engine === "jimeng" @@ -2482,7 +2514,7 @@ function defaultTemplateFormForEngine(engine: ImageGenerateEngine): TemplateForm } function sizeLabelFromSettings(width?: number, height?: number) { - return imageSizePresets.find((preset) => preset.width === width && preset.height === height)?.label || imageSizePresets[0].label; + return imageSizePresetFromDimensions(width, height)?.label || imageSizePresets[0].label; } function normalizeImageEngine(value?: string): ImageGenerateEngine | undefined { diff --git a/components/seedream-workspace.tsx b/components/seedream-workspace.tsx index 99751c6..a24189f 100644 --- a/components/seedream-workspace.tsx +++ b/components/seedream-workspace.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; -import { BoxSelect, Brush, Check, ImageIcon, Loader2, MapPin, Redo2, Trash2, Undo2, Upload, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { BoxSelect, Brush, Check, Hand, ImageIcon, Loader2, MapPin, Maximize2, Minimize2, Redo2, ScanLine, Trash2, Undo2, Upload, X, ZoomIn, ZoomOut } from "lucide-react"; import clsx from "clsx"; import { materialPreviewUrl } from "@/lib/client/asset-urls"; +import { canvasAnnotationPoint, constrainCanvasView, fitCanvasImage, FIT_CANVAS_VIEW, MAX_CANVAS_ZOOM, MIN_CANVAS_ZOOM, zoomCanvasAt, type CanvasSize, type CanvasView } from "@/lib/client/seedream-viewport"; import type { PromptMaterial } from "@/lib/prompt/assembler"; import { seedreamAnnotationToken, @@ -12,7 +13,7 @@ import { type SeedreamPoint } from "@/lib/seedream/creation"; -type AnnotationTool = "box" | "point" | "brush"; +type AnnotationTool = "box" | "point" | "brush" | "pan"; type BoxDraft = { start: SeedreamPoint; @@ -56,7 +57,21 @@ export function SeedreamWorkspace({ const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [redoStack, setRedoStack] = useState([]); const canvasRef = useRef(null); + const imageRef = useRef(null); + const workspaceRef = useRef(null); + const viewportRef = useRef(null); + const fullscreenButtonRef = useRef(null); + const drawingRef = useRef(false); + const panRef = useRef<{ pointerId: number; x: number; y: number; view: CanvasView } | null>(null); + const [view, setView] = useState(FIT_CANVAS_VIEW); + const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }); + const [imageSize, setImageSize] = useState({ width: 0, height: 0, key: "" }); + const [spaceHeld, setSpaceHeld] = useState(false); + const [panning, setPanning] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const [viewError, setViewError] = useState(null); const sourceKey = materialKey(source); + const fittedImage = fitCanvasImage(imageSize.key === sourceKey ? imageSize : { width: 0, height: 0 }, viewportSize); const selectedSourceKeys = useMemo(() => new Set(selectedSources.map(materialKey)), [selectedSources]); const sourceIndexByKey = useMemo(() => new Map(selectedSources.map((material, index) => [materialKey(material), index + 1])), [selectedSources]); const activeAnnotations = useMemo( @@ -72,8 +87,144 @@ export function SeedreamWorkspace({ setStrokeDraft(null); setSelectedAnnotationId(null); setRedoStack([]); + setView(FIT_CANVAS_VIEW); + setSpaceHeld(false); + setPanning(false); + panRef.current = null; + drawingRef.current = false; }, [sourceKey, mode]); + useEffect(() => { + // A cached image can finish loading before React attaches onLoad during + // hydration. Read its dimensions too, otherwise the fitted stage stays 0px. + const image = imageRef.current; + if (image?.complete && image.naturalWidth > 0) { + setImageSize({ key: sourceKey, width: image.naturalWidth, height: image.naturalHeight }); + } + }, [sourceKey]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const measure = () => setViewportSize({ width: viewport.clientWidth, height: viewport.clientHeight }); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(viewport); + return () => observer.disconnect(); + }, [sourceKey]); + + useEffect(() => { + setView((current) => constrainCanvasView(current, fittedImage, viewportSize)); + }, [fittedImage.width, fittedImage.height, viewportSize.width, viewportSize.height]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport || !fittedImage.width) return; + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + if (drawingRef.current || panRef.current) return; + const bounds = viewport.getBoundingClientRect(); + const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1; + const factor = Math.exp(-Math.max(-400, Math.min(400, event.deltaY * unit)) * 0.002); + const anchor = { x: event.clientX - bounds.left - bounds.width / 2, y: event.clientY - bounds.top - bounds.height / 2 }; + setView((current) => zoomCanvasAt(current, current.zoom * factor, anchor, fittedImage, viewportSize)); + }; + viewport.addEventListener("wheel", handleWheel, { passive: false }); + return () => viewport.removeEventListener("wheel", handleWheel); + }, [sourceKey, fittedImage.width, fittedImage.height, viewportSize.width, viewportSize.height]); + + useEffect(() => { + let wasActive = false; + const handleFullscreenChange = () => { + const active = document.fullscreenElement === workspaceRef.current; + if (!active && !wasActive) return; + wasActive = active; + setFullscreen(active); + setSpaceHeld(false); + cancelDraft(); + panRef.current = null; + setPanning(false); + if (!active) fullscreenButtonRef.current?.focus({ preventScroll: true }); + }; + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); + }, []); + + async function toggleFullscreen() { + setViewError(null); + try { + if (document.fullscreenElement === workspaceRef.current) await document.exitFullscreen(); + else if (workspaceRef.current?.requestFullscreen) await workspaceRef.current.requestFullscreen(); + else setViewError("当前浏览器不支持全屏编辑,可使用画布缩放和拖动。"); + } catch { + setViewError("无法进入全屏,请重试或使用画布缩放和拖动。"); + } + } + + function changeZoom(factor: number) { + if (drawingRef.current || panRef.current) return; + setView((current) => zoomCanvasAt(current, current.zoom * factor, { x: 0, y: 0 }, fittedImage, viewportSize)); + } + + function resetView() { + if (drawingRef.current || panRef.current) return; + setView(FIT_CANVAS_VIEW); + } + + function handleViewportPointerDown(event: ReactPointerEvent) { + if (!source || !event.isPrimary || (event.button !== 0 && event.button !== 1)) return; + event.currentTarget.focus({ preventScroll: true }); + if (tool !== "pan" && !spaceHeld && event.button !== 1) return; + event.preventDefault(); + event.stopPropagation(); + cancelDraft(); + panRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, view }; + setPanning(true); + event.currentTarget.setPointerCapture(event.pointerId); + } + + function handleViewportPointerMove(event: ReactPointerEvent) { + const pan = panRef.current; + if (!pan || pan.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + setView(constrainCanvasView({ ...pan.view, x: pan.view.x + event.clientX - pan.x, y: pan.view.y + event.clientY - pan.y }, fittedImage, viewportSize)); + } + + function stopPan(event: ReactPointerEvent) { + if (panRef.current?.pointerId !== event.pointerId) return; + event.stopPropagation(); + panRef.current = null; + setPanning(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + } + + function handleViewportKeyDown(event: ReactKeyboardEvent) { + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (event.key === " ") { + event.preventDefault(); + setSpaceHeld(true); + } else if (event.key === "+" || event.key === "=") { + event.preventDefault(); + changeZoom(1.25); + } else if (event.key === "-") { + event.preventDefault(); + changeZoom(0.8); + } else if (event.key === "0") { + event.preventDefault(); + resetView(); + } + } + + async function insertAnnotation(token: string) { + // The prompt editor lives outside the fullscreen element. Exit the same + // canvas (no remount) before moving focus there; all annotations survive. + if (document.fullscreenElement === workspaceRef.current) { + try { await document.exitFullscreen(); } catch { return; } + } + onInsertAnnotation(token); + } + useEffect(() => { if (mode === "layers" && tool === "point") setTool("box"); }, [mode, tool]); @@ -108,7 +259,7 @@ export function SeedreamWorkspace({ } function handlePointerDown(event: ReactPointerEvent) { - if (!source) return; + if (!source || !event.isPrimary || event.button !== 0 || tool === "pan" || spaceHeld || panRef.current) return; const point = pointerCoordinate(event, canvasRef.current); if (!point) return; if (tool === "point" && mode === "interactive") { @@ -116,6 +267,7 @@ export function SeedreamWorkspace({ return; } event.currentTarget.setPointerCapture(event.pointerId); + drawingRef.current = true; setSelectedAnnotationId(null); if (tool === "brush") { setStrokeDraft({ points: [point] }); @@ -125,6 +277,7 @@ export function SeedreamWorkspace({ } function handlePointerMove(event: ReactPointerEvent) { + if (!event.isPrimary) return; const point = pointerCoordinate(event, canvasRef.current); if (!point) return; if (boxDraft) { @@ -135,30 +288,35 @@ export function SeedreamWorkspace({ setStrokeDraft((current) => { if (!current) return null; const previous = current.points.at(-1); - if (previous && pointDistance(previous, point) < 3) return current; + if (previous && pointDistance(previous, point) < Math.max(1, 3 / view.zoom)) return current; return { points: [...current.points, point] }; }); } } function handlePointerUp(event: ReactPointerEvent) { + if (!event.isPrimary) return; + drawingRef.current = false; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + const end = pointerCoordinate(event, canvasRef.current); if (strokeDraft) { - const points = strokeDraft.points.length === 1 - ? [strokeDraft.points[0], { x: Math.min(1000, strokeDraft.points[0].x + 1), y: Math.min(1000, strokeDraft.points[0].y + 1) }] - : strokeDraft.points; + const drawn = end && pointDistance(strokeDraft.points.at(-1)!, end) >= 1 ? [...strokeDraft.points, end] : strokeDraft.points; + const points = drawn.length === 1 + ? [drawn[0], { x: Math.min(1000, drawn[0].x + 1), y: Math.min(1000, drawn[0].y + 1) }] + : drawn; setStrokeDraft(null); addAnnotation({ id: annotationID(), index: nextIndex, kind: "stroke", points }); return; } if (!boxDraft) return; - const box = normalizedBox(boxDraft.start, boxDraft.current); + const box = normalizedBox(boxDraft.start, end || boxDraft.current); setBoxDraft(null); - if (box.right - box.left < 8 || box.bottom - box.top < 8) return; + if (box.right - box.left < 2 || box.bottom - box.top < 2) return; addAnnotation({ id: annotationID(), index: nextIndex, kind: "box", box }); } function cancelDraft() { + drawingRef.current = false; setBoxDraft(null); setStrokeDraft(null); } @@ -166,7 +324,7 @@ export function SeedreamWorkspace({ const currentImageIndex = sourceIndexByKey.get(sourceKey) || 1; return ( -
+
{mode === "interactive" ? "精准编辑画布" : "指定拆分区域"} @@ -193,6 +351,7 @@ export function SeedreamWorkspace({
图{currentImageIndex} + + +
+ + {Math.round(view.zoom * 100)}% + + + +
-
-
- {source.name + {viewError ? {viewError} : null} +
{ if (event.key === " ") { event.preventDefault(); setSpaceHeld(false); } }} + onBlur={() => setSpaceHeld(false)} + > +
+ {source.name setImageSize({ key: sourceKey, width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })} />
+ 滚轮缩放 · 空格 / 中键拖动 · 放大后可继续标注 · 适应画布恢复全图 {mode === "interactive" ? "蓝色涂鸦会在提交时自动合成到对应输入图;框选和点选会转换成官方坐标标签。" : "蓝色涂鸦会合成到待拆图片;框选会转换为 坐标,不会修改原始素材。"} @@ -259,7 +441,7 @@ export function SeedreamWorkspace({ key={annotation.id} onClick={() => { setSelectedAnnotationId(annotation.id); - onInsertAnnotation(seedreamAnnotationToken(annotation.index, annotation.kind)); + void insertAnnotation(seedreamAnnotationToken(annotation.index, annotation.kind)); }} title="点击插入提示词" > @@ -391,12 +573,7 @@ function normalizedBox(start: SeedreamPoint, end: SeedreamPoint) { function pointerCoordinate(event: ReactPointerEvent, canvas: SVGSVGElement | null): SeedreamPoint | null { if (!canvas) return null; - const bounds = canvas.getBoundingClientRect(); - if (!bounds.width || !bounds.height) return null; - return { - x: clamp((event.clientX - bounds.left) / bounds.width * 1000), - y: clamp((event.clientY - bounds.top) / bounds.height * 1000) - }; + return canvasAnnotationPoint({ x: event.clientX, y: event.clientY }, canvas.getBoundingClientRect()); } function pointsPath(points: SeedreamPoint[]) { @@ -407,10 +584,6 @@ function pointDistance(a: SeedreamPoint, b: SeedreamPoint) { return Math.hypot(a.x - b.x, a.y - b.y); } -function clamp(value: number) { - return Math.max(0, Math.min(1000, Math.round(value))); -} - function materialKey(material?: PromptMaterial) { return material ? material.id || material.url : ""; } diff --git a/components/settings-panel.tsx b/components/settings-panel.tsx index a06ec08..5969009 100644 --- a/components/settings-panel.tsx +++ b/components/settings-panel.tsx @@ -31,6 +31,7 @@ type SettingsPayload = { seedance: boolean; seedream: boolean; bailian: boolean; + minimax: boolean; auth: boolean; organization: boolean; }; @@ -244,6 +245,7 @@ export function SettingsPanel() { +
@@ -317,6 +319,7 @@ function shortGroupLabel(id: string, title: string) { if (id === "visual") return "图片"; if (id === "evolink") return "EvoLink"; if (id === "seedance") return "方舟"; + if (id === "minimax") return "MiniMax"; if (id === "oss") return "OSS"; return title; } diff --git a/contracts/providers/http-v1.json b/contracts/providers/http-v1.json index d172689..e64d4ea 100644 --- a/contracts/providers/http-v1.json +++ b/contracts/providers/http-v1.json @@ -5,7 +5,8 @@ "evolink": {"submit": "/v1/images/generations", "query": "/v1/tasks/{id}", "auth": "bearer"}, "bailian": {"imageSubmit": "/api/v1/services/aigc/image-generation/generation", "videoSubmit": "/api/v1/services/aigc/video-generation/video-synthesis", "query": "/api/v1/tasks/{id}", "auth": "bearer"}, "seedance": {"model": "doubao-seedance-2-0-260128", "models": ["doubao-seedance-2-0-260128", "doubao-seedance-2-5-260628"], "submit": "/contents/generations/tasks", "query": "/contents/generations/tasks/{id}", "auth": "bearer", "maxMaterials": {"doubao-seedance-2-0-260128": 4, "doubao-seedance-2-5-260628": 50}, "maxDurationSeconds": {"doubao-seedance-2-0-260128": 15, "doubao-seedance-2-5-260628": 30}, "resolutions": ["480p", "720p", "1080p"]}, - "seedream": {"model": "doubao-seedream-5-0-pro-260628", "submit": "/images/generations", "auth": "bearer", "synchronous": true, "maxReferenceImages": 10, "sizes": ["1K", "1.5K", "2K"], "outputFormats": ["png", "jpeg"], "optimizeModes": ["standard", "fast"]} + "seedream": {"model": "doubao-seedream-5-0-pro-260628", "submit": "/images/generations", "auth": "bearer", "synchronous": true, "maxReferenceImages": 10, "sizes": ["1K", "1.5K", "2K"], "outputFormats": ["png", "jpeg"], "optimizeModes": ["standard", "fast"]}, + "minimax": {"model": "MiniMax-H3", "submit": "/v2/video_generation", "query": "/v2/query/video_generation/{id}", "auth": "bearer", "modes": ["text-to-video", "first-frame-to-video"], "maxMaterials": 1, "durationSeconds": {"min": 4, "max": 15}, "resolutions": ["768P", "2K"], "ratios": ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "adaptive"]} }, "errors": {"generic": true, "secretSafe": true}, "liveCallsInTests": false diff --git a/contracts/settings/runtime-v1.json b/contracts/settings/runtime-v1.json index 452ff23..901a07f 100644 --- a/contracts/settings/runtime-v1.json +++ b/contracts/settings/runtime-v1.json @@ -16,6 +16,8 @@ "EVOLINK_IMAGE_MODEL", "EVOLINK_IMAGE_QUALITY", "SEEDANCE_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", "BAILIAN_API_KEY", "BAILIAN_BASE_URL", "BAILIAN_IMAGE_MODEL", @@ -34,6 +36,7 @@ "VOLCENGINE_SECRET_ACCESS_KEY", "EVOLINK_API_KEY", "SEEDANCE_API_KEY", + "MINIMAX_API_KEY", "BAILIAN_API_KEY", "ALI_OSS_ACCESS_KEY_ID", "ALI_OSS_ACCESS_KEY_SECRET" @@ -60,6 +63,8 @@ "EVOLINK_IMAGE_MODEL", "EVOLINK_IMAGE_QUALITY", "SEEDANCE_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", "BAILIAN_API_KEY", "BAILIAN_BASE_URL", "BAILIAN_IMAGE_MODEL", diff --git a/contracts/usage/http-v1.json b/contracts/usage/http-v1.json index 85a527a..e76048d 100644 --- a/contracts/usage/http-v1.json +++ b/contracts/usage/http-v1.json @@ -6,6 +6,6 @@ ], "presets": ["today", "7d", "30d", "month"], "capabilities": ["image.generate", "video.generate"], - "providers": ["volcengine-visual", "evolink", "seedance", "seedream", "bailian"], + "providers": ["volcengine-visual", "evolink", "seedance", "seedream", "bailian", "minimax"], "infrastructureStatus": 500 } diff --git a/database/migrations/0006_minimax_h3_billing_provider.sql b/database/migrations/0006_minimax_h3_billing_provider.sql new file mode 100644 index 0000000..e3d28b3 --- /dev/null +++ b/database/migrations/0006_minimax_h3_billing_provider.sql @@ -0,0 +1,13 @@ +-- Allow MiniMax H3 rules in the durable billing catalog. Runtime provider +-- credentials already use the generic platform_runtime_settings table. + +begin; + +alter table public.billing_price_rules + drop constraint if exists billing_price_rules_provider_check; + +alter table public.billing_price_rules + add constraint billing_price_rules_provider_check + check (provider in ('volcengine-visual', 'evolink', 'seedance', 'seedream', 'bailian', 'minimax', 'mock')); + +commit; diff --git a/deploy/ack/secrets.example.yaml b/deploy/ack/secrets.example.yaml index eeb73af..92d9e16 100644 --- a/deploy/ack/secrets.example.yaml +++ b/deploy/ack/secrets.example.yaml @@ -37,6 +37,7 @@ stringData: EVOLINK_API_KEY: REPLACE_WITH_EVOLINK_API_KEY BAILIAN_API_KEY: REPLACE_WITH_BAILIAN_API_KEY SEEDANCE_API_KEY: REPLACE_WITH_SEEDANCE_API_KEY + MINIMAX_API_KEY: REPLACE_WITH_MINIMAX_API_KEY --- apiVersion: v1 kind: Secret diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 1d52292..8a9c453 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -60,7 +60,12 @@ Web 镜像或 ConfigMap。优先使用 RDS 内网地址,并仅对白名单中 3. `database/migrations/0003_platform_runtime_settings.sql` 4. `database/migrations/0004_seedream_billing_provider.sql` 5. `database/migrations/0005_seedream_layer_compositions.sql` -6. 应用角色授权语句(参考 `scripts/migrate-postgres.mjs`) +6. `database/migrations/0006_minimax_h3_billing_provider.sql` +7. 应用角色授权语句(参考 `scripts/migrate-postgres.mjs`) + +升级已有生产库接入 MiniMax H3 时,只需在发布新 Go 镜像前执行尚未应用的 `0006`; +新版本 `/api/ready` 会同时检查计费服务商约束中已包含 `seedream` 和 `minimax`,未迁移的 +Pod 不会进入 Ready,避免运行后才在报价阶段返回 500。 生产不部署迁移 Job。遇到历史重复数据时迁移会失败,需先人工审计,不能跳过或自动删除 计费与用量记录。仓库不再提供可误执行的 Node Migration Job 或 Node Worker 清单;后台 @@ -127,7 +132,7 @@ Kubernetes Secret: - 开放 API Key、Webhook 签名密钥等 `DATABASE_URL`、首个超级管理员凭据、开放 API Key 和 Webhook 密钥仍以 ACK -Secret/ConfigMap 为事实来源。设置页全部 25 项配置由超级管理员写入 +Secret/ConfigMap 为事实来源。设置页全部 27 项配置由超级管理员写入 `platform_runtime_settings`,数据库值优先于环境变量和本地文件回退值;环境中的认证、OSS 和服务商值只负责首次启动兜底。服务商配置、引擎选择和对公账户信息保存后立即生效;认证、 计费开关和 OSS 配置保存后需要重启 Go Deployment。未配置的服务商不会阻止 Go API 启动, diff --git a/lib/client/seedream-viewport.ts b/lib/client/seedream-viewport.ts new file mode 100644 index 0000000..3c6d162 --- /dev/null +++ b/lib/client/seedream-viewport.ts @@ -0,0 +1,39 @@ +export type CanvasSize = { width: number; height: number }; +export type CanvasView = { zoom: number; x: number; y: number }; +export type CanvasPosition = { x: number; y: number }; +export const MIN_CANVAS_ZOOM = 0.25; +export const MAX_CANVAS_ZOOM = 8; +export const FIT_CANVAS_VIEW: CanvasView = { zoom: 1, x: 0, y: 0 }; + +export function fitCanvasImage(image: CanvasSize, viewport: CanvasSize): CanvasSize { + if (image.width <= 0 || image.height <= 0 || viewport.width <= 0 || viewport.height <= 0) return { width: 0, height: 0 }; + const scale = Math.min(Math.max(1, viewport.width - 20) / image.width, Math.max(1, viewport.height - 20) / image.height); + return { width: image.width * scale, height: image.height * scale }; +} + +export function constrainCanvasView(view: CanvasView, fittedImage: CanvasSize, viewport: CanvasSize): CanvasView { + const zoom = Math.max(MIN_CANVAS_ZOOM, Math.min(MAX_CANVAS_ZOOM, Number.isFinite(view.zoom) ? view.zoom : 1)); + const maxX = Math.max(0, (fittedImage.width * zoom - viewport.width) / 2); + const maxY = Math.max(0, (fittedImage.height * zoom - viewport.height) / 2); + return { + zoom, + x: Math.max(-maxX, Math.min(maxX, Number.isFinite(view.x) ? view.x : 0)), + y: Math.max(-maxY, Math.min(maxY, Number.isFinite(view.y) ? view.y : 0)) + }; +} + +// The anchor is relative to the viewport center. Preserve the image point +// under the cursor when zooming, except where an image edge limits panning. +export function zoomCanvasAt(view: CanvasView, requestedZoom: number, anchor: CanvasPosition, fittedImage: CanvasSize, viewport: CanvasSize): CanvasView { + const zoom = Math.max(MIN_CANVAS_ZOOM, Math.min(MAX_CANVAS_ZOOM, requestedZoom)); + const factor = zoom / view.zoom; + return constrainCanvasView({ zoom, x: anchor.x - (anchor.x - view.x) * factor, y: anchor.y - (anchor.y - view.y) * factor }, fittedImage, viewport); +} + +// Use the *transformed image bounds*, never the clipped viewport bounds. +// Persisted annotations and provider prompts remain in the 0–1000 space. +export function canvasAnnotationPoint(point: CanvasPosition, bounds: CanvasSize & { left: number; top: number }): CanvasPosition | null { + if (bounds.width <= 0 || bounds.height <= 0) return null; + const clamp = (value: number) => Math.max(0, Math.min(1000, Math.round(value))); + return { x: clamp((point.x - bounds.left) / bounds.width * 1000), y: clamp((point.y - bounds.top) / bounds.height * 1000) }; +} diff --git a/lib/evolink/image-client.ts b/lib/evolink/image-client.ts index f5c5731..753e603 100644 --- a/lib/evolink/image-client.ts +++ b/lib/evolink/image-client.ts @@ -1,4 +1,5 @@ import type { EnabledImageCapability, GenerationStatus } from "@/lib/types"; +import { imageDimensionsForEngine } from "@/lib/image-size-presets"; export type ImageCreationEngine = "jimeng" | "evolink" | "bailian"; @@ -159,7 +160,8 @@ function assignSize(payload: Record, input: Record, engine: string) { + // EvoLink ignores resolution for explicit pixels. Stay within its existing + // 1K budget and align to 16px; the resulting A4 ratio differs by < 0.06%. + if (engine === "evolink") { + if (size.width === 1680 && size.height === 2376) return { width: 848, height: 1200 }; + if (size.width === 2376 && size.height === 1680) return { width: 1200, height: 848 }; + } + return { width: size.width, height: size.height }; +} + +export function imageSizePresetFromDimensions(width?: number, height?: number) { + return imageSizePresets.find((size) => { + const evo = imageDimensionsForEngine(size, "evolink"); + return size.width === width && size.height === height || evo.width === width && evo.height === height; + }); +} diff --git a/lib/minimax/client.ts b/lib/minimax/client.ts new file mode 100644 index 0000000..2579b9b --- /dev/null +++ b/lib/minimax/client.ts @@ -0,0 +1,161 @@ +import type { PromptMaterial } from "@/lib/prompt/assembler"; +import { toAbsoluteUrl } from "@/lib/server/runtime"; +import type { GenerationStatus } from "@/lib/types"; + +export const MINIMAX_H3_MODEL = "MiniMax-H3"; +export const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.cn"; + +export type MinimaxH3Settings = { + ratio?: string; + duration?: number; + resolution?: string; +}; + +export type MinimaxH3Usage = { + totalSeconds: number; + inputSeconds: number; + outputSeconds: number; + inputImageCount: number; +}; + +export function getMinimaxConfig() { + return { + apiKey: process.env.MINIMAX_API_KEY?.trim(), + baseUrl: (process.env.MINIMAX_BASE_URL || MINIMAX_DEFAULT_BASE_URL).replace(/\/+$/, ""), + model: MINIMAX_H3_MODEL + }; +} + +export function buildMinimaxH3Payload(input: { + prompt: string; + materials: PromptMaterial[]; + settings: MinimaxH3Settings; + origin: string; +}) { + const prompt = input.prompt.trim(); + if (!prompt) throw new Error("MiniMax H3 视频提示词不能为空。"); + if (input.materials.length > 1 || input.materials.some((material) => material.type !== "image")) { + throw new Error("MiniMax H3 第一版支持纯文字生成,或只上传 1 张图片作为首帧。"); + } + const duration = Number(input.settings.duration ?? 5); + if (!Number.isInteger(duration) || duration < 4 || duration > 15) { + throw new Error("MiniMax H3 视频时长须为 4–15 秒整数。"); + } + const resolution = String(input.settings.resolution || "768P").toUpperCase(); + if (resolution !== "768P" && resolution !== "2K") { + throw new Error("MiniMax H3 分辨率仅支持 768P 或 2K。"); + } + const ratio = input.materials.length === 1 ? "adaptive" : String(input.settings.ratio || "16:9"); + if (input.materials.length === 0 && !["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(ratio)) { + throw new Error("MiniMax H3 文生视频须选择固定画面比例。"); + } + return { + model: MINIMAX_H3_MODEL, + content: [ + { type: "text", text: prompt }, + ...input.materials.map((material) => ({ + type: "image_url", + image_url: { url: toAbsoluteUrl(material.url, input.origin) }, + role: "first_frame" + })) + ], + resolution, + duration, + ratio, + aigc_watermark: false + }; +} + +export async function createMinimaxH3Task(payload: ReturnType) { + const config = requiredConfig(); + const response = await minimaxFetch(`${config.baseUrl}/v2/video_generation`, config.apiKey, { + method: "POST", + body: JSON.stringify(payload) + }); + const providerTaskId = text(response.task_id); + if (!providerTaskId) throw new Error("MiniMax H3 响应中缺少任务 ID。"); + return { providerTaskId, raw: response }; +} + +export async function queryMinimaxH3Task(providerTaskId: string) { + const config = requiredConfig(); + const response = await minimaxFetch(`${config.baseUrl}/v2/query/video_generation/${encodeURIComponent(providerTaskId)}`, config.apiKey); + const task = record(response.task); + const content = record(task.content); + const error = record(task.error); + return { + status: normalizeStatus(task.status), + resultUrl: text(content.url), + errorMessage: minimaxTaskError(text(error.code), text(error.message)), + usage: extractMinimaxH3Usage(task.usage), + raw: response + }; +} + +export function extractMinimaxH3Usage(value: unknown): MinimaxH3Usage | undefined { + const usage = record(value); + const outputSeconds = nonNegativeInteger(usage.output_seconds); + if (!outputSeconds) return undefined; + return { + totalSeconds: nonNegativeInteger(usage.total_seconds), + inputSeconds: nonNegativeInteger(usage.input_seconds), + outputSeconds, + inputImageCount: nonNegativeInteger(usage.input_image_count) + }; +} + +async function minimaxFetch(url: string, apiKey: string, init: RequestInit = {}) { + const response = await fetch(url, { + ...init, + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...(init.headers || {}) } + }); + const json = await response.json().catch(() => ({})) as Record; + if (!response.ok) throw new Error(minimaxHTTPError(response.status)); + return json; +} + +function requiredConfig() { + const config = getMinimaxConfig(); + if (!config.apiKey) throw new Error("缺少 MINIMAX_API_KEY,请先配置 MiniMax 凭据。"); + return { ...config, apiKey: config.apiKey }; +} + +function minimaxHTTPError(status: number) { + if (status === 401) return "MiniMax 鉴权失败,请检查 API Key 是否正确且已开通 H3。"; + if (status === 403) return "MiniMax API Key 无 H3 调用权限,请确认已购买并开通该模型。"; + if (status === 402) return "MiniMax API 账户余额不足,请充值后重试。"; + if (status === 404) return "MiniMax H3 接口或模型不可用,请检查 Base URL 和账号模型权限。"; + if (status === 422) return "视频描述或输入素材触发内容安全审核,请调整后重试。"; + if (status === 429) return "MiniMax 请求频率过高,请稍后重试。"; + if (status >= 500) return "MiniMax 服务暂时不可用,请稍后重试。"; + return "MiniMax 请求参数不符合要求,请检查提示词、素材和生成参数。"; +} + +function minimaxTaskError(code?: string, message?: string) { + if (code === "1008") return "MiniMax API 账户余额不足,请充值后重试。"; + if (code === "1026") return "视频描述或输入素材触发内容安全审核,请调整后重试。"; + if (code === "1002") return "MiniMax 请求频率过高,请稍后重试。"; + return message ? "MiniMax 视频生成失败,请调整提示词或素材后重试。" : undefined; +} + +function normalizeStatus(value: unknown): GenerationStatus { + const status = String(value || "").toLowerCase(); + if (status === "succeeded") return "succeeded"; + if (status === "failed") return "failed"; + if (status === "cancelled") return "cancelled"; + if (status === "running") return "running"; + return "queued"; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function text(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function nonNegativeInteger(value: unknown) { + const number = Number(value); + return Number.isInteger(number) && number >= 0 ? number : 0; +} diff --git a/lib/server/app-settings.ts b/lib/server/app-settings.ts index db75259..9a3adca 100644 --- a/lib/server/app-settings.ts +++ b/lib/server/app-settings.ts @@ -9,6 +9,7 @@ import { rootDir } from "@/lib/server/runtime"; import { getVisualClientConfig } from "@/lib/volcengine/visual-client"; import type { EnabledImageCapability } from "@/lib/types"; import { getBailianConfig } from "@/lib/bailian/client"; +import { getMinimaxConfig, MINIMAX_H3_MODEL } from "@/lib/minimax/client"; type SettingsImageCreationEngine = ImageCreationEngine | "seedream"; const SEEDREAM_50_PRO_MODEL = "doubao-seedream-5-0-pro-260628"; @@ -132,6 +133,15 @@ const settingDefinitions: Array<{ { key: "BAILIAN_VIDEO_MODEL", label: "视频模型", defaultValue: "wan2.7-i2v-2026-04-25" } ] }, + { + id: "minimax", + title: "MiniMax API", + description: "用于 MiniMax H3 文生视频与单首帧图生视频。", + fields: [ + { key: "MINIMAX_API_KEY", label: "MiniMax API Key", secret: true, type: "password" }, + { key: "MINIMAX_BASE_URL", label: "Base URL", defaultValue: "https://api.minimax.cn" } + ] + }, { id: "oss", title: "OSS 资产存储", @@ -166,6 +176,7 @@ const engineFieldDefinitions: FieldDefinition[] = [ defaultValue: "bailian", options: [ { label: "Seedance", value: "seedance" }, + { label: "MiniMax H3", value: "minimax" }, { label: "阿里云百炼 Wan 2.7", value: "bailian" } ] }, @@ -199,6 +210,7 @@ export async function getApiSettings() { seedance: Boolean(seedance.apiKey), seedream: Boolean(seedance.apiKey), bailian: Boolean(getBailianConfig().apiKey), + minimax: Boolean(getMinimaxConfig().apiKey), auth: authConfigSummary(auth) === "configured", organization: platformAccountStoreConfigured() }, @@ -254,9 +266,12 @@ function buildEngineAssignments(fileEnv: Map): EngineAssignment[ const evolink = getEvolinkImageSettings(); const seedance = getSeedanceConfig(); const bailian = getBailianConfig(); + const minimax = getMinimaxConfig(); const generateEngine = currentEngineValue(engineFieldDefinitions[0], fileEnv); const videoEngine = currentVideoEngineValue(engineFieldDefinitions[1], fileEnv); - const videoConnected = videoEngine === "bailian" ? Boolean(bailian.apiKey) : Boolean(seedance.apiKey); + const videoConnected = videoEngine === "bailian" + ? Boolean(bailian.apiKey) + : videoEngine === "minimax" ? Boolean(minimax.apiKey) : Boolean(seedance.apiKey); return [ imageEngineAssignment({ @@ -271,10 +286,10 @@ function buildEngineAssignments(fileEnv: Map): EngineAssignment[ id: "video.generate", label: "视频生成", engine: videoEngine, - engineLabel: videoEngine === "bailian" ? "阿里云百炼" : "Seedance", + engineLabel: videoEngine === "bailian" ? "阿里云百炼" : videoEngine === "minimax" ? "MiniMax H3" : "Seedance", connected: videoConnected, connectionLabel: videoConnected ? "已连接" : "待配置", - reqKey: videoEngine === "bailian" ? bailian.videoModel : seedance.model, + reqKey: videoEngine === "bailian" ? bailian.videoModel : videoEngine === "minimax" ? MINIMAX_H3_MODEL : seedance.model, configurable: true, field: fieldWithValue(engineFieldDefinitions[1], videoEngine, isConfigured(engineFieldDefinitions[1].key, fileEnv)) } @@ -339,7 +354,9 @@ function imageEngineConnected(engine: SettingsImageCreationEngine) { } function currentVideoEngineValue(field: FieldDefinition, fileEnv: Map) { - return (process.env[field.key] ?? fileEnv.get(field.key) ?? field.defaultValue) === "bailian" ? "bailian" : "seedance"; + const value = (process.env[field.key] ?? fileEnv.get(field.key) ?? field.defaultValue)?.trim().toLowerCase(); + if (value === "bailian" || value === "minimax") return value; + return "seedance"; } function envFilePath() { diff --git a/lib/server/billing-catalog.ts b/lib/server/billing-catalog.ts index 8c909f5..4a995b5 100644 --- a/lib/server/billing-catalog.ts +++ b/lib/server/billing-catalog.ts @@ -16,6 +16,7 @@ const EVOLINK_IMAGE_URL = "https://evolink.ai/zh/gpt-image-2"; const VOLC_ARK_PRICING_URL = "https://www.volcengine.com/docs/82379/1544106?lang=zh"; const JIMENG_PRICING_URL = "https://www.volcengine.com/activity/jimeng"; const SEEDREAM_PRICING_URL = "https://docs.volcengine.com/docs/82379/1544106?lang=zh"; +const MINIMAX_PRICING_URL = "https://platform.minimaxi.com/docs/guides/pricing-paygo#视频"; const SEEDREAM_50_PRO_DIMENSIONS = [ { @@ -65,6 +66,8 @@ const EVOLINK_GPT_IMAGE_DIMENSIONS = [ { value: "3:4", label: "3:4", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }, { value: "16:9", label: "16:9", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }, { value: "9:16", label: "9:16", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }, + { value: "70:99", label: "A4 竖版", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }, + { value: "99:70", label: "A4 横版", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }, { value: "other", label: "其他比例", match: {}, standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true, note: "同一分辨率档位下按相同比例预算估算。" } ] }, @@ -328,6 +331,44 @@ export const DEFAULT_BILLING_PRICE_RULES: BillingPriceRuleInput[] = [ basis: "doubao-seedance-2.5,1080P,无输入视频、16:9、5 秒示例折算", observedAt: BILLING_CATALOG_OBSERVED_AT } + }, + { + id: "base-minimax-h3-768p", + provider: "minimax", + capability: "video.generate", + reqKey: "MiniMax-H3", + variantKey: "resolution=768p", + unit: "video_second", + standardUnitPriceFen: 50, + markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, + enabled: true, + note: "MiniMax H3 768P 输出 ¥0.50/秒;首五张输入图免费。", + source: { + url: MINIMAX_PRICING_URL, + currency: "CNY", + unitPrice: 0.5, + basis: "MiniMax H3 768P 输出视频,按成功视频秒数计费", + observedAt: "2026-09-04" + } + }, + { + id: "base-minimax-h3-2k", + provider: "minimax", + capability: "video.generate", + reqKey: "MiniMax-H3", + variantKey: "resolution=2k", + unit: "video_second", + standardUnitPriceFen: 80, + markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, + enabled: true, + note: "MiniMax H3 2K 输出 ¥0.80/秒;首五张输入图免费。", + source: { + url: MINIMAX_PRICING_URL, + currency: "CNY", + unitPrice: 0.8, + basis: "MiniMax H3 2K 输出视频,按成功视频秒数计费", + observedAt: "2026-09-04" + } } ]; diff --git a/lib/server/billing-service.ts b/lib/server/billing-service.ts index 793f86f..12da4b9 100644 --- a/lib/server/billing-service.ts +++ b/lib/server/billing-service.ts @@ -17,6 +17,7 @@ import { import { getGenerationJob, updateGenerationJob } from "@/lib/server/data-store"; import { SEEDANCE_20_MODEL, SEEDANCE_25_MODEL } from "@/lib/video-settings"; import { calculateSeedreamAmountFen, SEEDREAM_50_PRO_MODEL } from "@/lib/server/seedream-billing"; +import { calculateMinimaxH3ActualAmountFen, MINIMAX_H3_MODEL, minimaxH3VideoPriceFenPerSecond } from "@/lib/server/minimax-billing"; import type { BillingAccountConfig, BillingConditionValue, @@ -99,6 +100,9 @@ export async function quoteGenerationCharge(input: BillingGenerationInput): Prom } }; } + if (input.provider === "minimax" && input.reqKey === MINIMAX_H3_MODEL) { + return { ...quote, reservedAmountFen: quote.amountFen, settlementStatus: "pending" }; + } if (input.provider !== "seedance" || !isSeedanceBillingModel(input.reqKey)) { return quote; } @@ -131,7 +135,7 @@ export async function chargeGenerationJob(job: GenerationJob): Promise { + const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId; + if (!job) return null; + const billing = job.billing; + const quotaExempt = isQuotaExemptBilling(job); + const organizationId = job.usageContext?.organizationId; + const chargeReady = billing?.status === "charged" || quotaExempt && billing?.status === "not_charged"; + if (job.provider !== "minimax" || job.reqKey !== MINIMAX_H3_MODEL || !billing || !chargeReady || !quotaExempt && !organizationId) return job; + if (billing.settlementStatus === "settled" || billing.settlementStatus === "estimated") return job; + if (!usage || !Number.isFinite(usage.outputSeconds) || usage.outputSeconds <= 0) { + return updateGenerationJob(job.id, { billing: { ...billing, settlementStatus: "estimated", settlementReason: "provider_usage_unavailable", settledAt: new Date().toISOString() } }); + } + + const resolution = billing.parameters?.resolution; + const actualAmountFen = calculateMinimaxH3ActualAmountFen({ + resolution, outputSeconds: usage.outputSeconds, inputVideoSeconds: usage.inputSeconds, + inputImageCount: usage.inputImageCount, markupMultiplier: billing.markupMultiplier + }); + const deltaFen = actualAmountFen - billing.amountFen; + let settlementLedgerEntryId: string | undefined; + let settledAt = new Date().toISOString(); + if (deltaFen !== 0 && !quotaExempt && organizationId) { + const settlement = await postWalletEntry({ + organizationId, accountId: job.usageContext?.accountId, jobId: job.id, + kind: deltaFen > 0 ? "charge" : "refund", deltaFen: -deltaFen, + idempotencyKey: `job-settlement:${job.id}`, + description: deltaFen > 0 ? `${capabilityLabel(job.capability)}实际用量补扣` : `${capabilityLabel(job.capability)}实际用量差额退回`, + metadata: { + operation: "minimax_h3_actual_settlement", reservedAmountFen: billing.reservedAmountFen ?? billing.amountFen, + actualAmountFen, outputSeconds: usage.outputSeconds, inputVideoSeconds: usage.inputSeconds, + inputImageCount: usage.inputImageCount, resolution: String(resolution || "768P"), model: job.reqKey + } + }); + settlementLedgerEntryId = settlement.entry.id; + settledAt = settlement.entry.createdAt; + } + return updateGenerationJob(job.id, { + billing: { + ...billing, amountFen: actualAmountFen, settlementStatus: "settled", settlementLedgerEntryId, settledAt, + providerUsage: { + model: job.reqKey, resolution: String(resolution || "768P"), outputSeconds: usage.outputSeconds, + inputVideoSeconds: usage.inputSeconds, inputImageCount: usage.inputImageCount, + videoPriceFenPerSecond: minimaxH3VideoPriceFenPerSecond(resolution) + } + } + }); +} + function isSeedanceBillingModel(model: string) { return model === SEEDANCE_20_MODEL || model === SEEDANCE_25_MODEL; } @@ -418,8 +473,8 @@ export function normalizeBillingParameters(requestPayload: Record Math.abs(ratio - numerator / denominator) < 0.02); return match ? `${match[0]}:${match[1]}` : ratio.toFixed(3).replace(/0+$/, "").replace(/\.$/, ""); } diff --git a/lib/server/data-store.ts b/lib/server/data-store.ts index 252d46e..497ca2e 100644 --- a/lib/server/data-store.ts +++ b/lib/server/data-store.ts @@ -759,18 +759,7 @@ function billingJobChargeFromValue(value: unknown): GenerationJob["billing"] { settlementLedgerEntryId: optionalString(value.settlementLedgerEntryId), settledAt: optionalString(value.settledAt), settlementReason: optionalString(value.settlementReason), - providerUsage: isRecord(value.providerUsage) - && Number.isFinite(Number(value.providerUsage.completionTokens)) - && typeof value.providerUsage.resolution === "string" - && typeof value.providerUsage.inputVideo === "boolean" - && Number.isFinite(Number(value.providerUsage.tokenPriceFenPerMillion)) - ? { - completionTokens: Number(value.providerUsage.completionTokens), - resolution: value.providerUsage.resolution, - inputVideo: value.providerUsage.inputVideo, - tokenPriceFenPerMillion: Number(value.providerUsage.tokenPriceFenPerMillion) - } - : undefined, + providerUsage: billingProviderUsageFromValue(value.providerUsage), ledgerEntryId: optionalString(value.ledgerEntryId), refundLedgerEntryId: optionalString(value.refundLedgerEntryId), chargedAt: optionalString(value.chargedAt), @@ -779,6 +768,30 @@ function billingJobChargeFromValue(value: unknown): GenerationJob["billing"] { }; } +function billingProviderUsageFromValue(value: unknown): NonNullable["providerUsage"] { + if (!isRecord(value) || typeof value.resolution !== "string") return undefined; + const completionTokens = optionalNumber(value.completionTokens); + const tokenPriceFenPerMillion = optionalNumber(value.tokenPriceFenPerMillion); + const outputSeconds = optionalNumber(value.outputSeconds); + const inputVideoSeconds = optionalNumber(value.inputVideoSeconds); + const inputImageCount = optionalNumber(value.inputImageCount); + const videoPriceFenPerSecond = optionalNumber(value.videoPriceFenPerSecond); + const inputVideo = typeof value.inputVideo === "boolean" ? value.inputVideo : undefined; + const model = optionalString(value.model); + if (completionTokens === undefined && outputSeconds === undefined) return undefined; + return { + resolution: value.resolution, + model, + completionTokens, + inputVideo, + tokenPriceFenPerMillion, + outputSeconds, + inputVideoSeconds, + inputImageCount, + videoPriceFenPerSecond + }; +} + function imageTemplateToRow(template: Partial) { const row: Record = {}; if (template.id !== undefined) row.id = template.id; diff --git a/lib/server/database.ts b/lib/server/database.ts index daaa92b..c4155e0 100644 --- a/lib/server/database.ts +++ b/lib/server/database.ts @@ -65,6 +65,17 @@ export async function checkDatabaseReadiness(): Promise { WHERE to_regclass('public.' || table_name) IS NULL OR NOT has_table_privilege(current_user, 'public.' || table_name, privilege_name) ) + AND EXISTS ( + SELECT 1 + FROM pg_constraint AS constraint_record + JOIN pg_class AS constrained_table ON constrained_table.oid = constraint_record.conrelid + JOIN pg_namespace AS constrained_schema ON constrained_schema.oid = constrained_table.relnamespace + WHERE constrained_schema.nspname = 'public' + AND constrained_table.relname = 'billing_price_rules' + AND constraint_record.conname = 'billing_price_rules_provider_check' + AND position('seedream' IN pg_get_constraintdef(constraint_record.oid)) > 0 + AND position('minimax' IN pg_get_constraintdef(constraint_record.oid)) > 0 + ) AND has_function_privilege( current_user, 'public.claim_generation_jobs(text,integer,integer)', diff --git a/lib/server/minimax-billing.ts b/lib/server/minimax-billing.ts new file mode 100644 index 0000000..b4fecae --- /dev/null +++ b/lib/server/minimax-billing.ts @@ -0,0 +1,31 @@ +export const MINIMAX_H3_MODEL = "MiniMax-H3"; + +export function minimaxH3VideoPriceFenPerSecond(resolution: unknown) { + return String(resolution || "").trim().toUpperCase() === "2K" ? 80 : 50; +} + +export function calculateMinimaxH3ActualAmountFen(input: { + resolution?: unknown; + outputSeconds: number; + inputVideoSeconds?: number; + inputImageCount?: number; + markupMultiplier: number; +}) { + const outputSeconds = integer(input.outputSeconds); + const inputVideoSeconds = nonNegativeInteger(input.inputVideoSeconds); + const inputImageCount = nonNegativeInteger(input.inputImageCount); + if (outputSeconds <= 0) throw new Error("MiniMax H3 实际输出时长必须大于 0。"); + if (!Number.isFinite(input.markupMultiplier) || input.markupMultiplier < 1) throw new Error("MiniMax H3 计费倍率不能低于 1.00。"); + const standardAmountFen = (outputSeconds + inputVideoSeconds) * minimaxH3VideoPriceFenPerSecond(input.resolution) + + Math.max(0, inputImageCount - 5) * 20; + return Math.ceil(standardAmountFen * input.markupMultiplier); +} + +function integer(value: unknown) { + const number = Number(value); + return Number.isInteger(number) ? number : 0; +} + +function nonNegativeInteger(value: unknown) { + return Math.max(0, integer(value)); +} diff --git a/lib/server/video-generation-service.ts b/lib/server/video-generation-service.ts index fefd832..5b6cf9b 100644 --- a/lib/server/video-generation-service.ts +++ b/lib/server/video-generation-service.ts @@ -5,15 +5,16 @@ import { recordUsageForJob, updateGenerationJob } from "@/lib/server/data-store"; -import { chargeGenerationJob, quoteGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service"; +import { chargeGenerationJob, quoteGenerationCharge, settleMinimaxGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service"; import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; import { importRemoteAssetAsAsset } from "@/lib/server/storage"; import { createSeedanceTask, getSeedanceConfig, querySeedanceTask, type SeedanceSettings } from "@/lib/seedance/client"; import type { BillingJobCharge, GenerationJob, UsageContext } from "@/lib/types"; import { VIDEO_DURATION_DEFAULT, clampVideoDuration, isSupportedSeedanceModel, normalizeSeedanceModel, normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution } from "@/lib/video-settings"; import { bailianResultUrls, bailianStatus, bailianTaskId, buildBailianVideoPayload, getBailianConfig, queryBailianTask, submitBailianTask } from "@/lib/bailian/client"; +import { buildMinimaxH3Payload, createMinimaxH3Task, getMinimaxConfig, MINIMAX_H3_MODEL, queryMinimaxH3Task } from "@/lib/minimax/client"; -export type VideoCreationEngine = "seedance" | "bailian"; +export type VideoCreationEngine = "seedance" | "bailian" | "minimax"; export type SubmitVideoJobInput = PromptAssemblyInput & { model?: string; @@ -40,7 +41,7 @@ type PreparedVideoGeneration = { finalPrompt: string; settings: SeedanceSettings; missingProviderCredentials: boolean; - provider: "bailian" | "seedance"; + provider: "bailian" | "seedance" | "minimax"; reqKey: string; requestPayload: Record; }; @@ -110,8 +111,10 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string) function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): PreparedVideoGeneration { const ownerId = input.ownerId || DEFAULT_OWNER_ID; - const configuredEngine = process.env.VIDEO_GENERATE_ENGINE === "seedance" ? "seedance" : "bailian"; - const engine: VideoCreationEngine = input.engine === "seedance" || input.engine === "bailian" + const configuredEngine: VideoCreationEngine = process.env.VIDEO_GENERATE_ENGINE === "seedance" + ? "seedance" + : process.env.VIDEO_GENERATE_ENGINE === "minimax" ? "minimax" : "bailian"; + const engine: VideoCreationEngine = input.engine === "seedance" || input.engine === "bailian" || input.engine === "minimax" ? input.engine : configuredEngine; const config = getSeedanceConfig(); @@ -130,13 +133,20 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre settings.duration = normalizeVideoDuration(settings.duration) ?? 10; settings.resolution = String(settings.resolution || "720P").toUpperCase(); buildBailianVideoPayload({ prompt: finalPrompt, materials: assembled.materials, origin, settings }); + } else if (engine === "minimax") { + const payload = buildMinimaxH3Payload({ prompt: finalPrompt, materials: assembled.materials, settings, origin }); + settings.duration = payload.duration; + settings.resolution = payload.resolution; + settings.ratio = payload.ratio; } else { settings.ratio = normalizeVideoRatio(settings.ratio, config.ratio); settings.duration = normalizeVideoDuration(settings.duration, model) ?? clampVideoDuration(config.duration, VIDEO_DURATION_DEFAULT, { model }); settings.resolution = normalizeVideoResolution(settings.resolution, model, config.resolution); } - const missingProviderCredentials = engine === "bailian" ? !getBailianConfig().apiKey : !config.apiKey; + const missingProviderCredentials = engine === "bailian" + ? !getBailianConfig().apiKey + : engine === "minimax" ? !getMinimaxConfig().apiKey : !config.apiKey; const requestPayload = { input, assembled, @@ -153,12 +163,13 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre settings, missingProviderCredentials, provider: engine, - reqKey: engine === "bailian" ? getBailianConfig().videoModel : model, + reqKey: engine === "bailian" ? getBailianConfig().videoModel : engine === "minimax" ? MINIMAX_H3_MODEL : model, requestPayload }; } function missingProviderMessage(engine: VideoCreationEngine) { + if (engine === "minimax") return "缺少 MINIMAX_API_KEY,请先配置 MiniMax 凭据。"; return engine === "bailian" ? "缺少 BAILIAN_API_KEY 或 DASHSCOPE_API_KEY,请先配置阿里云百炼凭据。" : "缺少 SEEDANCE_API_KEY,请先配置 Seedance 凭据。"; @@ -187,6 +198,11 @@ async function dispatchVideoJob(job: GenerationJob, origin: string): Promise, - completionTokens?: number + completionTokens?: number, + minimaxUsage?: { outputSeconds: number; inputSeconds: number; inputImageCount: number } ) { const settledJob = job.provider === "seedance" ? await settleSeedanceGenerationCharge(job, completionTokens) || job - : job; + : job.provider === "minimax" + ? await settleMinimaxGenerationCharge(job, minimaxUsage) || job + : job; const asset = await importRemoteAssetAsAsset({ ownerId: settledJob.ownerId, url: resultUrl, diff --git a/lib/types.ts b/lib/types.ts index 46304cc..0bb3f82 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -13,6 +13,7 @@ export type GenerationProvider = | "seedance" | "seedream" | "bailian" + | "minimax" | "mock"; export type BillingCurrency = "CNY"; @@ -118,11 +119,15 @@ export type BillingJobCharge = BillingQuote & { settledAt?: string; settlementReason?: string; providerUsage?: { - completionTokens: number; + completionTokens?: number; model?: string; resolution: string; - inputVideo: boolean; - tokenPriceFenPerMillion: number; + inputVideo?: boolean; + tokenPriceFenPerMillion?: number; + outputSeconds?: number; + inputVideoSeconds?: number; + inputImageCount?: number; + videoPriceFenPerSecond?: number; }; ledgerEntryId?: string; refundLedgerEntryId?: string; diff --git a/lib/usage.ts b/lib/usage.ts index c6502e3..8b245bd 100644 --- a/lib/usage.ts +++ b/lib/usage.ts @@ -118,6 +118,7 @@ export function providerLabel(provider?: GenerationProvider): string { if (provider === "seedance") return "Seedance"; if (provider === "seedream") return "Seedream 5.0 Pro"; if (provider === "bailian") return "阿里云百炼"; + if (provider === "minimax") return "MiniMax H3"; if (provider === "mock") return "历史任务"; return "未知服务商"; } diff --git a/supabase/schema.sql b/supabase/schema.sql index d421300..831ef20 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -290,7 +290,7 @@ create index if not exists seedream_layer_compositions_owner_updated_idx create table if not exists billing_price_rules ( id text primary key, - provider text not null check (provider in ('volcengine-visual', 'evolink', 'seedance', 'seedream', 'bailian', 'mock')), + provider text not null check (provider in ('volcengine-visual', 'evolink', 'seedance', 'seedream', 'bailian', 'minimax', 'mock')), capability text not null check (capability in ('image.generate', 'video.generate')), req_key text, variant_key text, diff --git a/tests/a4-billing.test.ts b/tests/a4-billing.test.ts new file mode 100644 index 0000000..d6529ae --- /dev/null +++ b/tests/a4-billing.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { normalizeBillingParameters } from "@/lib/server/billing-service"; +import { DEFAULT_BILLING_PRICE_RULES } from "@/lib/server/billing-catalog"; + +describe("A4 billing metadata", () => { + it.each([[1680, 2376, "70:99"], [2376, 1680, "99:70"], [848, 1200, "70:99"], [1200, 848, "99:70"]])("recognizes %s × %s without changing the markup", (width, height, aspectRatio) => { + const dimensions = { width, height }; + for (const payload of [dimensions, { input: dimensions }, { settings: dimensions }, { providerPayload: dimensions }]) { + expect(normalizeBillingParameters(payload)).toMatchObject({ aspectRatio, size: `${width}*${height}` }); + } + const rule = DEFAULT_BILLING_PRICE_RULES.find((rule) => rule.id === "base-evolink-gpt-image-2")!; + const dimension = rule.parameterDimensions!.find((dimension) => dimension.key === "aspectRatio")!; + expect(dimension.tiers.find((tier) => tier.value === aspectRatio)).toMatchObject({ standardFactor: 1, markupMultiplier: 1.2, enabled: true }); + }); +}); diff --git a/tests/create-studio-template-interaction.test.ts b/tests/create-studio-template-interaction.test.ts index 5e27bbb..7d2a9f8 100644 --- a/tests/create-studio-template-interaction.test.ts +++ b/tests/create-studio-template-interaction.test.ts @@ -7,7 +7,9 @@ describe("create studio template interaction", () => { it("offers the shared 3:4 portrait preset in creation and template settings", async () => { const source = await readFile(createStudioUrl, "utf8"); - expect(source).toContain('{ label: "3:4", width: 1728, height: 2304 }'); + const presets = await readFile(new URL("../lib/image-size-presets.ts", import.meta.url), "utf8"); + expect(presets).toContain('{ label: "3:4", width: 1728, height: 2304 }'); + expect(source).toContain('from "@/lib/image-size-presets"'); expect(source.match(/imageSizePresets\.map/g)?.length || 0).toBeGreaterThanOrEqual(2); }); diff --git a/tests/data-store-minimax-billing.test.ts b/tests/data-store-minimax-billing.test.ts new file mode 100644 index 0000000..dfc2a88 --- /dev/null +++ b/tests/data-store-minimax-billing.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { queryDatabase } = vi.hoisted(() => ({ queryDatabase: vi.fn() })); + +vi.mock("@/lib/server/database", () => ({ + isPostgresBackend: () => true, + queryDatabase, + withDatabaseTransaction: vi.fn() +})); + +import { getGenerationJob } from "@/lib/server/data-store"; + +describe("generation job MiniMax billing mapping", () => { + beforeEach(() => queryDatabase.mockReset()); + + it("retains MiniMax actual usage when a PostgreSQL job is read", async () => { + queryDatabase.mockResolvedValueOnce({ + rows: [{ + id: "job-h3", + owner_id: "account-1", + capability: "video.generate", + provider: "minimax", + req_key: "MiniMax-H3", + status: "succeeded", + input_asset_ids: [], + input_urls: [], + output_asset_ids: ["asset-1"], + request_payload: {}, + billing: { + priceRuleId: "base-minimax-h3-2k", + provider: "minimax", + capability: "video.generate", + reqKey: "MiniMax-H3", + unit: "video_second", + quantity: 5, + standardUnitPriceFen: 80, + markupMultiplier: 1.2, + amountFen: 480, + currency: "CNY", + status: "charged", + settlementStatus: "settled", + providerUsage: { + model: "MiniMax-H3", + resolution: "2K", + outputSeconds: 5, + inputVideoSeconds: 0, + inputImageCount: 1, + videoPriceFenPerSecond: 80 + } + }, + created_at: new Date("2026-09-04T01:00:00.000Z"), + updated_at: new Date("2026-09-04T01:05:00.000Z") + }] + }); + + const job = await getGenerationJob("job-h3"); + + expect(job?.billing?.providerUsage).toEqual({ + resolution: "2K", + model: "MiniMax-H3", + completionTokens: undefined, + inputVideo: undefined, + tokenPriceFenPerMillion: undefined, + outputSeconds: 5, + inputVideoSeconds: 0, + inputImageCount: 1, + videoPriceFenPerSecond: 80 + }); + }); +}); diff --git a/tests/database-readiness-contract.test.ts b/tests/database-readiness-contract.test.ts index 776d12d..4cce363 100644 --- a/tests/database-readiness-contract.test.ts +++ b/tests/database-readiness-contract.test.ts @@ -25,6 +25,9 @@ describe("PostgreSQL readiness contract", () => { ]) { expect(source).toContain(`('${table}',`); } + expect(source).toContain("billing_price_rules_provider_check"); + expect(source).toContain("position('seedream'"); + expect(source).toContain("position('minimax'"); expect(source).toContain("claim_generation_jobs(text,integer,integer)"); expect(source).toContain("billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)"); }); diff --git a/tests/image-size-presets.test.ts b/tests/image-size-presets.test.ts new file mode 100644 index 0000000..5320a14 --- /dev/null +++ b/tests/image-size-presets.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { imageDimensionsForEngine, imageSizePresets, imageSizePresetFromDimensions } from "@/lib/image-size-presets"; +import { buildEvolinkImagePayload } from "@/lib/evolink/image-client"; +import { validateBailianImageSize } from "@/lib/bailian/client"; + +describe("A4 generation presets", () => { + it("retains the five existing presets unchanged", () => { + expect(imageSizePresets.slice(0, 5)).toEqual([ + { label: "1:1", width: 2048, height: 2048 }, + { label: "4:3", width: 2304, height: 1728 }, + { label: "3:4", width: 1728, height: 2304 }, + { label: "16:9", width: 2560, height: 1440 }, + { label: "9:16", width: 1440, height: 2560 } + ]); + }); + + it.each(imageSizePresets.filter((size) => size.paper === "a4"))("round trips $label through template dimensions for every regular engine", (size) => { + expect(Math.min(size.width, size.height) / Math.max(size.width, size.height)).toBeCloseTo(210 / 297, 8); + for (const engine of ["jimeng", "bailian", "evolink"]) { + const dimensions = imageDimensionsForEngine(size, engine); + expect(imageSizePresetFromDimensions(dimensions.width, dimensions.height)).toEqual(size); + if (engine === "bailian") { + expect(() => validateBailianImageSize(dimensions.width, dimensions.height, true)).not.toThrow(); + } + } + }); + + it.each(imageSizePresets.filter((size) => size.paper === "a4"))("keeps $label on EvoLink's 1K budget rather than sending 4MP custom pixels", (size) => { + const dimensions = imageDimensionsForEngine(size, "evolink"); + expect(dimensions.width % 16).toBe(0); + expect(dimensions.height % 16).toBe(0); + expect(dimensions.width * dimensions.height).toBeGreaterThanOrEqual(655360); + expect(dimensions.width * dimensions.height).toBeLessThanOrEqual(1024 ** 2); + expect(Math.min(dimensions.width, dimensions.height) / Math.max(dimensions.width, dimensions.height)).toBeCloseTo(210 / 297, 3); + // Both a new frontend payload and an older template's canonical dimensions + // are adapted before sending to the upstream. + for (const input of [size, dimensions]) { + const payload = buildEvolinkImagePayload("image.generate", { prompt: "A4 行程单", ...input }, { baseUrl: "https://api.evolink.ai", model: "gpt-image-2" }); + expect(payload).toMatchObject({ size: `${dimensions.width}x${dimensions.height}`, resolution: "1K" }); + } + }); +}); diff --git a/tests/minimax-billing.test.ts b/tests/minimax-billing.test.ts new file mode 100644 index 0000000..9a934a4 --- /dev/null +++ b/tests/minimax-billing.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { calculateMinimaxH3ActualAmountFen, minimaxH3VideoPriceFenPerSecond } from "@/lib/server/minimax-billing"; + +describe("MiniMax H3 billing", () => { + it("uses official 768P and 2K rates with the platform 1.2 markup", () => { + expect(minimaxH3VideoPriceFenPerSecond("768P")).toBe(50); + expect(minimaxH3VideoPriceFenPerSecond("2k")).toBe(80); + expect(calculateMinimaxH3ActualAmountFen({ resolution: "768P", outputSeconds: 5, inputImageCount: 1, markupMultiplier: 1.2 })).toBe(300); + expect(calculateMinimaxH3ActualAmountFen({ resolution: "2K", outputSeconds: 5, inputImageCount: 1, markupMultiplier: 1.2 })).toBe(480); + }); +}); diff --git a/tests/minimax-client.test.ts b/tests/minimax-client.test.ts new file mode 100644 index 0000000..8a04a85 --- /dev/null +++ b/tests/minimax-client.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildMinimaxH3Payload, createMinimaxH3Task, extractMinimaxH3Usage, queryMinimaxH3Task } from "@/lib/minimax/client"; + +describe("MiniMax H3 V2 client", () => { + afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_BASE_URL; + }); + + it("builds text and single first-frame payloads with bounded parameters", () => { + expect(buildMinimaxH3Payload({ prompt: "move", materials: [], settings: { duration: 5, resolution: "2K", ratio: "3:4" }, origin: "https://app.test" })).toMatchObject({ + model: "MiniMax-H3", duration: 5, resolution: "2K", ratio: "3:4", aigc_watermark: false, + content: [{ type: "text", text: "move" }] + }); + const image = buildMinimaxH3Payload({ + prompt: "animate", materials: [{ type: "image", url: "/asset.png", label: "@图片1" }], + settings: { duration: 8, resolution: "768p", ratio: "16:9" }, origin: "https://app.test" + }); + expect(image).toMatchObject({ + resolution: "768P", ratio: "adaptive", + content: [ + { type: "text", text: "animate" }, + { type: "image_url", image_url: { url: "https://app.test/asset.png" }, role: "first_frame" } + ] + }); + expect(() => buildMinimaxH3Payload({ prompt: "bad", materials: [{ type: "video", url: "/a.mp4" }], settings: {}, origin: "https://app.test" })).toThrow("只上传 1 张图片"); + }); + + it("submits and queries the official asynchronous endpoints", async () => { + process.env.MINIMAX_API_KEY = "test-key"; + process.env.MINIMAX_BASE_URL = "https://minimax.test"; + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ task_id: "task-h3" }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ task: { id: "task-h3", status: "succeeded", content: { url: "https://cdn.test/video.mp4" }, usage: { total_seconds: 5, input_seconds: 0, output_seconds: 5, input_image_count: 1 } } }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + const payload = buildMinimaxH3Payload({ prompt: "move", materials: [], settings: {}, origin: "https://app.test" }); + await expect(createMinimaxH3Task(payload)).resolves.toMatchObject({ providerTaskId: "task-h3" }); + await expect(queryMinimaxH3Task("task-h3")).resolves.toMatchObject({ + status: "succeeded", resultUrl: "https://cdn.test/video.mp4", usage: { outputSeconds: 5, inputImageCount: 1 } + }); + expect(String(fetchMock.mock.calls[0][0])).toBe("https://minimax.test/v2/video_generation"); + expect(String(fetchMock.mock.calls[1][0])).toBe("https://minimax.test/v2/query/video_generation/task-h3"); + }); + + it("extracts billable usage", () => { + expect(extractMinimaxH3Usage({ total_seconds: 8, input_seconds: 0, output_seconds: 8, input_image_count: 1 })).toEqual({ totalSeconds: 8, inputSeconds: 0, outputSeconds: 8, inputImageCount: 1 }); + expect(extractMinimaxH3Usage({ output_seconds: 0 })).toBeUndefined(); + }); +}); diff --git a/tests/providers-contract.test.ts b/tests/providers-contract.test.ts index 42be8d0..57dc662 100644 --- a/tests/providers-contract.test.ts +++ b/tests/providers-contract.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; describe("Go provider adapter contract", () => { it("freezes bounded adapters and safe errors", async () => { const contract = JSON.parse(await readFile(new URL("../contracts/providers/http-v1.json", import.meta.url), "utf8")); - expect(Object.keys(contract.providers)).toEqual(["volcengine-visual", "evolink", "bailian", "seedance", "seedream"]); + expect(Object.keys(contract.providers)).toEqual(["volcengine-visual", "evolink", "bailian", "seedance", "seedream", "minimax"]); expect(contract.providers["volcengine-visual"]).toMatchObject({ model: "jimeng_seedream46_cvtob", submitAction: "JimengSeedream46CVToBSubmitTask", @@ -36,6 +36,15 @@ describe("Go provider adapter contract", () => { outputFormats: ["png", "jpeg"], optimizeModes: ["standard", "fast"] }); + expect(contract.providers.minimax).toMatchObject({ + model: "MiniMax-H3", + submit: "/v2/video_generation", + query: "/v2/query/video_generation/{id}", + modes: ["text-to-video", "first-frame-to-video"], + maxMaterials: 1, + durationSeconds: { min: 4, max: 15 }, + resolutions: ["768P", "2K"] + }); expect(contract.errors).toEqual({ generic: true, secretSafe: true }); expect(contract.liveCallsInTests).toBe(false); }); diff --git a/tests/seedream-viewport.test.ts b/tests/seedream-viewport.test.ts new file mode 100644 index 0000000..21c2596 --- /dev/null +++ b/tests/seedream-viewport.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { canvasAnnotationPoint, constrainCanvasView, fitCanvasImage, FIT_CANVAS_VIEW, zoomCanvasAt } from "@/lib/client/seedream-viewport"; +import { buildSeedreamInteractivePrompt } from "@/lib/seedream/creation"; + +describe("Seedream canvas view geometry", () => { + const viewport = { width: 800, height: 500 }; + + it.each([{ width: 1000, height: 2000 }, { width: 2000, height: 1000 }])("fits a $width × $height image without stretching or inner scrollbars", (image) => { + const fitted = fitCanvasImage(image, viewport); + expect(fitted.width).toBeLessThanOrEqual(780); + expect(fitted.height).toBeLessThanOrEqual(480); + expect(fitted.width / fitted.height).toBeCloseTo(image.width / image.height); + }); + + it("keeps the cursor over the same image point during zoom", () => { + const fitted = fitCanvasImage({ width: 1000, height: 1000 }, viewport); + const before = { zoom: 2, x: 30, y: -20 }; + const anchor = { x: 100, y: 80 }; + const after = zoomCanvasAt(before, 3, anchor, fitted, viewport); + expect((anchor.x - after.x) / after.zoom).toBeCloseTo((anchor.x - before.x) / before.zoom); + expect((anchor.y - after.y) / after.zoom).toBeCloseTo((anchor.y - before.y) / before.zoom); + }); + + it("constrains pan at image edges and restores a centered fit", () => { + const fitted = { width: 240, height: 480 }; + expect(constrainCanvasView({ zoom: 3, x: 900, y: -9999 }, fitted, viewport)).toEqual({ zoom: 3, x: 0, y: -470 }); + expect(constrainCanvasView(FIT_CANVAS_VIEW, fitted, viewport)).toEqual(FIT_CANVAS_VIEW); + expect(constrainCanvasView({ zoom: 99, x: 0, y: 0 }, fitted, viewport).zoom).toBe(8); + expect(constrainCanvasView({ zoom: 0.01, x: 0, y: 0 }, fitted, viewport).zoom).toBe(0.25); + }); + + it("maps zoomed and panned box, point and brush coordinates back to the original image", () => { + const normalized = [{ x: 100, y: 200 }, { x: 456, y: 321 }, { x: 700, y: 800 }]; + for (const bounds of [ + { left: 100, top: 50, width: 400, height: 600 }, + { left: -720, top: -380, width: 1600, height: 2400 }, + { left: 320, top: 120, width: 800, height: 1200 } + ]) { + const points = normalized.map((point) => canvasAnnotationPoint({ x: bounds.left + bounds.width * point.x / 1000, y: bounds.top + bounds.height * point.y / 1000 }, bounds)); + expect(points).toEqual(normalized); + expect(buildSeedreamInteractivePrompt("修改 @标注1", [{ id: "point", index: 1, kind: "point", point: points[1]! }])).toBe("修改 图1456 321"); + } + }); + + it("clamps drawing outside the image and ignores an unloaded image", () => { + expect(canvasAnnotationPoint({ x: -1, y: 999 }, { left: 0, top: 0, width: 100, height: 100 })).toEqual({ x: 0, y: 1000 }); + expect(canvasAnnotationPoint({ x: 0, y: 0 }, { left: 0, top: 0, width: 0, height: 0 })).toBeNull(); + }); +}); diff --git a/tests/seedream-workspace-layout-contract.test.ts b/tests/seedream-workspace-layout-contract.test.ts index e7df470..d648ebe 100644 --- a/tests/seedream-workspace-layout-contract.test.ts +++ b/tests/seedream-workspace-layout-contract.test.ts @@ -36,7 +36,25 @@ describe("Seedream workspace layout contract", () => { expect(viewport).toMatch(/--seedream-canvas-height\s*:\s*clamp\(280px,\s*38vh,\s*460px\)/); expect(viewport).toMatch(/height\s*:\s*var\(--seedream-canvas-height\)/); expect(viewport).toMatch(/flex\s*:\s*0\s+0\s+auto/); - expect(image).toMatch(/max-height\s*:\s*calc\(var\(--seedream-canvas-height\)\s*-\s*22px\)/); + expect(image).toMatch(/width\s*:\s*100%/); + expect(image).toMatch(/height\s*:\s*100%/); + expect(workspaceSource).toContain("fitCanvasImage"); + expect(workspaceSource).toContain("width: fittedImage.width, height: fittedImage.height"); + }); + + it("uses the same editor in native fullscreen and keeps panning inside a clipped viewport", () => { + expect(workspaceSource).toContain("workspaceRef.current.requestFullscreen()"); + expect(workspaceSource).not.toContain("createPortal"); + expect(cssRule(".seedream-workspace:fullscreen")).toMatch(/overflow\s*:\s*hidden/); + expect(cssRule(".seedream-workspace:fullscreen .seedream-canvas-viewport")).toMatch(/flex\s*:\s*1\s+1\s+auto/); + expect(workspaceSource).toContain('addEventListener("wheel", handleWheel, { passive: false })'); + expect(workspaceSource).toContain('removeEventListener("wheel", handleWheel)'); + }); + + it("measures cached images that finish loading before hydration", () => { + expect(workspaceSource).toContain("image?.complete && image.naturalWidth > 0"); + expect(workspaceSource).toContain("ref={imageRef}"); + expect(workspaceSource).toContain("key: sourceKey, width: image.naturalWidth, height: image.naturalHeight"); }); it("does not render the redundant source picker for single-image layer decomposition", () => {