diff --git a/.env.example b/.env.example index c659abf..fa417e2 100644 --- a/.env.example +++ b/.env.example @@ -60,7 +60,7 @@ DATABASE_APPLICATION_NAME=zhinian-web # NEXT_PUBLIC_SUPABASE_ANON_KEY= # SUPABASE_SERVICE_ROLE_KEY= -# Image creation engine: jimeng, evolink, or bailian. +# Image creation engine: jimeng, seedream, evolink, or bailian. IMAGE_GENERATE_ENGINE=jimeng # Volcengine Visual API for Jimeng image capabilities. @@ -77,12 +77,13 @@ EVOLINK_BASE_URL=https://api.evolink.ai EVOLINK_IMAGE_MODEL=gpt-image-2 EVOLINK_IMAGE_QUALITY=medium -# Seedance / Volcengine Ark +# Volcengine Ark (shared by Seedance video and Seedream 5.0 Pro image generation) SEEDANCE_API_KEY= SEEDANCE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +# 默认模型;创作页仍可逐任务选择 Seedance 2.0 或 2.5。 SEEDANCE_MODEL=doubao-seedance-2-0-260128 SEEDANCE_RATIO=9:16 -# Seedance 2.0 duration supports integer seconds from 4 to 15, or -1 for model auto. +# Creation UI/API: Seedance 2.0 supports 4-15 seconds; Seedance 2.5 supports 4-30 seconds. SEEDANCE_DURATION=5 SEEDANCE_RESOLUTION=720p diff --git a/README.md b/README.md index a92542e..bd70ebd 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ https://你的域名/api/auth/callback 当前组织余额由超级管理员直接上账,充值和人工余额调整不选择个人归属;未来接入用户自主支付时,支付成功回调自动入账,不设置人工审核队列。余额不足或未配置对应计费规则时,真实生成任务不会提交给服务商。未绑定组织的开放 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/秒;EvoLink `gpt-image-2` medium/1K/1:1/无参考图基础估算 ¥0.34/张(固定汇率 1 USD = 7.20 CNY),并列出质量、分辨率、画面比例和参考图数量档位;即梦 `jimeng_seedream46_cvtob` 暂按公开资源包折算参考 ¥0.20/张,官方实时计费以控制台为准。参数化报价按基础成本乘以所选参数档位系数,组合倍率取所选档位中的最高倍率;超管只在 `/billing` 调整倍率,标准成本和参数档案由平台维护。 +系统首次打开超管计费中心或提交真实任务时会自动补齐平台标准价格目录,不覆盖已有目录。当前默认目录为:百炼 `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` 调整倍率,标准成本和参数档案由平台维护。 任务会快照账号、租户和组织归属,用量记录不会随任务、素材或账号删除。统计统一采用 `Asia/Shanghai`,明细不展示提示词、素材或生成结果。 @@ -133,21 +133,25 @@ https://你的域名/api/auth/callback 图片生成支持在设置页「状态」里按功能切换创作引擎: - `jimeng`:默认引擎,走火山 Visual 即梦能力。 +- `seedream`:走火山方舟 Seedream 5.0 Pro 同步图片接口,支持基础文生图、图文生图和多图融合。 - `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. ## 即梦图片能力 -V1 接入图片生成能力: +V1 保留两套独立的火山图片能力: - `image.generate`:即梦图片生成 4.6,默认 `req_key=jimeng_seedream46_cvtob` +- `image.generate` 配合 `engine=seedream`:Seedream 5.0 Pro 基础生图,模型 `doubao-seedream-5-0-pro-260628` -后端统一走火山 Visual 异步任务: +即梦 4.6 继续走火山 Visual 异步任务: - 提交:`JimengSeedream46CVToBSubmitTask` - 查询:`JimengSeedream46CVToBGetResult` - API Version:`2024-06-06` +Seedream 5.0 Pro 使用火山方舟同步接口 `POST /api/v3/images/generations`;返回图片会立即导入平台资产存储。 + 未配置火山密钥时,服务会明确报告凭据未配置,不会生成占位图片。 ## EvoLink 图片能力 @@ -165,7 +169,7 @@ V1 接入图片生成能力: `/create` 是新的统一生成入口,按即梦“图片/视频能力在同一个创作产品内切换”的方式组织: - 一个提示词框:图片和视频都在同一创作面板内编辑最终提示词。 -- 模式切换:图片模式走即梦图片生成 4.6;视频模式走 Seedance 视频生成。 +- 模式切换:图片模式可逐任务选择即梦 4.6、Seedream 5.0 Pro、EvoLink 或百炼;视频模式可选择 Seedance 或百炼。 - 素材统一上传:一个入口上传图片、视频或音频,不再拆分参考图、主体、分镜等栏目。 - Seedance 2.0 单次最多提交 4 个素材(加上文本后 `content` 最多 5 项)。 - `@素材` 引用:上传后自动绑定为 `@图片1`、`@视频1`、`@音频1`,chip 和 @ 候选项都显示缩略图。 @@ -173,7 +177,7 @@ V1 接入图片生成能力: - 任务模块:创作页右侧直接展示任务列表,点击任务可查看完整提示词、输入要素、生成参数、状态和结果。 - 结果保存:图片和视频生成结果会写入资产记录,并在任务详情和任务卡中提供预览与下载。 -未配置 `SEEDANCE_API_KEY` 时,服务会明确报告凭据未配置,不会生成占位视频。 +未配置 `SEEDANCE_API_KEY` 时,服务会明确报告凭据未配置,不会生成占位视频或 Seedream 图片。该方舟 API Key 由 Seedance 与 Seedream 5.0 Pro 共用。 ## 环境变量 @@ -206,7 +210,7 @@ cp .env.example .env.local - `ZHINIAN_STAFF_API_TOKEN` - `ZHINIAN_ORG_TENANT_ID` - `ZHINIAN_ORG_ID` -- `IMAGE_GENERATE_ENGINE=jimeng` 或 `evolink` +- `IMAGE_GENERATE_ENGINE=jimeng`、`seedream`、`evolink` 或 `bailian` - `VOLCENGINE_ACCESS_KEY_ID` - `VOLCENGINE_SECRET_ACCESS_KEY` - `VOLCENGINE_REGION=cn-north-1` @@ -237,7 +241,7 @@ cp .env.example .env.local database/migrations/ ``` -首次部署和每次 schema 变更均按 `database/migrations/*.sql` 中的版本化 SQL 文件手工执行(依次执行 0001、0002、0003,再加角色授权语句),不部署迁移 Job Pod;执行完成后再滚动工作负载。 +首次部署和每次 schema 变更均按 `database/migrations/*.sql` 中的版本化 SQL 文件手工执行(依次执行 0001、0002、0003、0004,再加角色授权语句),不部署迁移 Job Pod;执行完成后再滚动工作负载。 当前仍保留必要数据表,供上传、生成任务和用量记录使用: diff --git a/README.zh-CN.md b/README.zh-CN.md index ad3d354..c28a382 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,7 +22,7 @@ - 个人用量:点击页头账号 ID 查看真实任务次数 - 用量管理:`/usage`,管理员可见 - 计费中心:`/billing`,所有组织成员可见,超级管理员负责规则、组织余额与直接上账 -- 图片生成:即梦图片生成 4.6 或 EvoLink GPT Image 2 +- 图片生成:即梦 4.6、Seedream 5.0 Pro、EvoLink GPT Image 2 或阿里云百炼 - 视频生成:Seedance 2.0 - 素材引用:上传后可在提示词中使用 `@图片1`、`@视频1`、`@音频1` - 生产链路:未配置真实接口时明确报错,不生成占位结果 @@ -141,7 +141,7 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码 ## 组织账号管理 -组织、账号、角色、停用、密码重置和归档均由平台本地接口处理,不再调用外部组织服务。生产部署前由部署负责人手工执行 [`database/migrations`](./database/migrations) 中的版本化 SQL 文件(依次执行 0001、0002、0003)及角色授权语句完成建库建表,不部署迁移 Job Pod;超级管理员由 Go 后端首次启动时通过 `ZHINIAN_BOOTSTRAP_ADMIN_*` 环境变量自动创建一次。 +组织、账号、角色、停用、密码重置和归档均由平台本地接口处理,不再调用外部组织服务。生产部署前由部署负责人手工执行 [`database/migrations`](./database/migrations) 中的版本化 SQL 文件(依次执行 0001、0002、0003、0004)及角色授权语句完成建库建表,不部署迁移 Job Pod;超级管理员由 Go 后端首次启动时通过 `ZHINIAN_BOOTSTRAP_ADMIN_*` 环境变量自动创建一次。 ## 账号、组织用量与计费 @@ -167,10 +167,15 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码 | 火山方舟 `doubao-seedance-2-0-260128` | 720P | ¥0.99 | 每秒 | | 火山方舟 `doubao-seedance-2-0-260128` | 1080P | ¥2.48 | 每秒 | | 火山方舟 `doubao-seedance-2-0-260128` | 4K | ¥5.05 | 每秒 | +| 火山方舟 `doubao-seedance-2-5-260628` | 480P | ¥0.67 | 每秒 | +| 火山方舟 `doubao-seedance-2-5-260628` | 720P | ¥1.51 | 每秒 | +| 火山方舟 `doubao-seedance-2-5-260628` | 1080P | ¥3.74 | 每秒 | | EvoLink `gpt-image-2` | medium / 1K / 1:1 / 无参考图基础估算 | ¥0.34 | 每张 | | 即梦 `jimeng_seedream46_cvtob` | 公开资源包折算参考 | ¥0.20 | 每张 | +| 火山方舟 `doubao-seedream-5-0-pro-260628` | 1K / 1.5K 基础生图 | ¥0.30 | 每张 | +| 火山方舟 `doubao-seedream-5-0-pro-260628` | 2K 基础生图 | ¥0.60 | 每张 | -其中 EvoLink 按固定 `1 USD = 7.20 CNY` 换算,并在价格目录中列出质量、分辨率、画面比例和参考图数量档位;参数化报价按基础成本乘以所选档位系数,组合倍率取所选档位中的最高倍率。即梦 4.6 官方计费说明要求以控制台实时价格为准,因此该条目是平台维护的参考基准。超管只在价格目录中调整上浮倍率,标准成本、参数档案和规则状态由平台维护;来源链接和定价口径会随规则保留。 +其中 Seedream 5.0 Pro 首张输入图免费,从第 2 张起按 ¥0.02/张计入基础成本;平台统一在基础成本合计后乘以 1.2 并向上取整到分。EvoLink 按固定 `1 USD = 7.20 CNY` 换算,并在价格目录中列出质量、分辨率、画面比例和参考图数量档位;参数化报价按基础成本乘以所选档位系数,组合倍率取所选档位中的最高倍率。即梦 4.6 官方计费说明要求以控制台实时价格为准,因此该条目是平台维护的参考基准。超管只在价格目录中调整上浮倍率,标准成本、参数档案和规则状态由平台维护;来源链接和定价口径会随规则保留。 使用 PostgreSQL 时,首次部署和每次 schema 变更均按 `database/migrations/*.sql` 中的版本化 SQL 文件手工执行(拒绝已应用脚本被静默改写),执行完成后再滚动工作负载。`local` 模式下,本地 JSON 数据会在读取时按相同口径兼容旧记录。 @@ -181,6 +186,7 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码 图片生成可在设置页按能力切换: - `jimeng`:火山 Visual 即梦能力 +- `seedream`:火山方舟 Seedream 5.0 Pro 基础生图,支持文生图、图文生图和多图融合 - `evolink`:EvoLink GPT Image 2 中转接口 - `bailian`:阿里云百炼万相 2.7,支持文生图、最多 9 张参考图生图,以及 1–2 张首尾帧图生视频 @@ -273,7 +279,7 @@ cp .env.example .env.local | `ZHINIAN_WEBHOOK_SECRET` | Webhook 签名密钥,可选 | | `ZHINIAN_WORKER_*` | Worker 间隔、批量、锁超时、重试配置 | | `ZHINIAN_GO_EMBEDDED_WORKER` | Go 内嵌 Worker 开关,生产设为 `true` | -| `IMAGE_GENERATE_ENGINE` | 图片生成引擎:`jimeng`、`evolink` 或 `bailian` | +| `IMAGE_GENERATE_ENGINE` | 图片生成引擎:`jimeng`、`seedream`、`evolink` 或 `bailian` | | `BAILIAN_API_KEY` | 阿里云百炼 API Key | | `BAILIAN_BASE_URL` | 百炼业务空间兼容地址;系统自动派生原生异步接口 | | `BAILIAN_IMAGE_MODEL` | 图片模型,默认 `wan2.7-image-pro` | @@ -282,7 +288,7 @@ cp .env.example .env.local | `VOLCENGINE_ACCESS_KEY_ID` | 火山引擎 Access Key | | `VOLCENGINE_SECRET_ACCESS_KEY` | 火山引擎 Secret Key | | `EVOLINK_API_KEY` | EvoLink API Key | -| `SEEDANCE_API_KEY` | 火山方舟 Seedance API Key | +| `SEEDANCE_API_KEY` | 火山方舟 API Key,由 Seedance 视频与 Seedream 5.0 Pro 图片共用 | | `SEEDANCE_MODEL` | Seedance 模型 ID | | `SEEDANCE_RATIO` | 默认视频比例 | | `SEEDANCE_DURATION` | 默认视频秒数 | diff --git a/app/globals.css b/app/globals.css index eeb7003..28a6bf6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2456,7 +2456,7 @@ h3 { .settings-service-strip { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; } diff --git a/backend/internal/application/runtime.go b/backend/internal/application/runtime.go index 9499e65..5ba992b 100644 --- a/backend/internal/application/runtime.go +++ b/backend/internal/application/runtime.go @@ -78,7 +78,7 @@ func applicationID(prefix string) string { func imageEngine(getenv postgres.Getenv) string { engine := strings.ToLower(firstNonEmpty(getenv("IMAGE_GENERATE_ENGINE"), getenv("IMAGE_CREATION_ENGINE"), getenv("IMAGE_PROVIDER"), "jimeng")) - if engine != "evolink" && engine != "bailian" { + if engine != "evolink" && engine != "bailian" && engine != "seedream" { return "jimeng" } return engine @@ -97,6 +97,8 @@ func imageProvider(getenv postgres.Getenv) string { return "evolink" case "bailian": return "bailian" + case "seedream": + return "seedream" default: return "volcengine-visual" } @@ -115,6 +117,8 @@ func imageModel(getenv postgres.Getenv) string { return firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2") case "bailian": return firstNonEmpty(getenv("BAILIAN_IMAGE_MODEL"), "wan2.7-image-pro") + case "seedream": + return providers.Seedream50ProModel default: return firstNonEmpty(getenv("JIMENG_IMAGE_GENERATE_46_REQ_KEY"), "jimeng_seedream46_cvtob") } @@ -157,6 +161,7 @@ func buildProviderRegistryWithClient(getenv postgres.Getenv, client *http.Client "evolink": providers.NewEvoLink(providers.Config{BaseURL: firstNonEmpty(getenv("EVOLINK_BASE_URL"), "https://api.evolink.ai"), APIKey: getenv("EVOLINK_API_KEY"), Model: firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2"), MaxResponseBytes: maximum}, 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), } } @@ -175,19 +180,21 @@ func providerImageTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget Provider: "bailian", Model: firstNonEmpty(getenv("BAILIAN_IMAGE_MODEL"), "wan2.7-image-pro"), }, + "seedream": { + Provider: "seedream", + Model: providers.Seedream50ProModel, + Settings: map[string]any{"size": "1.5K", "outputFormat": "png", "optimizeMode": "standard", "imageCount": float64(1)}, + }, } } func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget { + seedanceModel := firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128") return map[string]jobs.ProviderTarget{ "seedance": { Provider: "seedance", - Model: firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128"), - Settings: map[string]any{ - "ratio": firstNonEmpty(getenv("SEEDANCE_RATIO"), getenv("SEEDANCE_DEFAULT_RATIO"), "9:16"), - "duration": float64(positiveInt64Value(firstNonEmpty(getenv("SEEDANCE_DURATION"), getenv("SEEDANCE_DEFAULT_DURATION")), 5)), - "resolution": firstNonEmpty(getenv("SEEDANCE_RESOLUTION"), getenv("SEEDANCE_DEFAULT_RESOLUTION"), "720p"), - }, + Model: seedanceModel, + Settings: seedanceDefaultSettings(getenv, seedanceModel), }, "bailian": { Provider: "bailian", @@ -196,6 +203,37 @@ func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget } } +func providerVideoModels(getenv postgres.Getenv) map[string]jobs.ProviderTarget { + models := []string{ + "doubao-seedance-2-0-260128", + "doubao-seedance-2-5-260628", + firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128"), + } + targets := make(map[string]jobs.ProviderTarget, len(models)) + for _, model := range models { + targets[model] = jobs.ProviderTarget{Provider: "seedance", Model: model, Settings: seedanceDefaultSettings(getenv, model)} + } + return targets +} + +func seedanceDefaultSettings(getenv postgres.Getenv, model string) map[string]any { + duration := positiveInt64Value(firstNonEmpty(getenv("SEEDANCE_DURATION"), getenv("SEEDANCE_DEFAULT_DURATION")), 5) + maximum := int64(15) + if model == "doubao-seedance-2-5-260628" { + maximum = 30 + } + if duration < 4 { + duration = 4 + } else if duration > maximum { + duration = maximum + } + return map[string]any{ + "ratio": firstNonEmpty(getenv("SEEDANCE_RATIO"), getenv("SEEDANCE_DEFAULT_RATIO"), "9:16"), + "duration": float64(duration), + "resolution": firstNonEmpty(getenv("SEEDANCE_RESOLUTION"), getenv("SEEDANCE_DEFAULT_RESOLUTION"), "720p"), + } +} + func providerUnavailableMessages(getenv postgres.Getenv) map[string]string { missing := map[string]string{} if strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) == "" || strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) == "" { @@ -209,6 +247,7 @@ func providerUnavailableMessages(getenv postgres.Getenv) map[string]string { } if strings.TrimSpace(getenv("SEEDANCE_API_KEY")) == "" { missing["seedance"] = "Seedance 服务商未配置,请先配置 SEEDANCE_API_KEY。" + missing["seedream"] = "Seedream 服务商未配置,请先配置火山方舟 API Key(SEEDANCE_API_KEY)。" } return missing } @@ -223,7 +262,7 @@ func defaultWebhookSender(getenv postgres.Getenv) (*webhook.HTTPSender, error) { func capabilitySummary(getenv postgres.Getenv) func(context.Context) (any, error) { return func(context.Context) (any, error) { engine := imageEngine(getenv) - provider := map[string]string{"jimeng": "volcengine-visual", "evolink": "evolink", "bailian": "bailian"}[engine] + provider := map[string]string{"jimeng": "volcengine-visual", "evolink": "evolink", "bailian": "bailian", "seedream": "seedream"}[engine] primaryVideoEngine := videoEngine(getenv) primaryVideo := map[string]any{ "id": "video.generate", "kind": "video", "engine": primaryVideoEngine, @@ -231,21 +270,33 @@ func capabilitySummary(getenv postgres.Getenv) func(context.Context) (any, error } if primaryVideoEngine == "seedance" { primaryVideo["label"] = "Seedance 视频生成" - primaryVideo["limits"] = seedanceCapabilityLimits() + primaryVideo["limits"] = seedanceCapabilityLimits(videoModel(getenv)) + primaryVideo["models"] = seedanceModelOptions() } else { primaryVideo["label"] = "百炼图生视频" primaryVideo["limits"] = bailianVideoCapabilityLimits() } return []any{ - map[string]any{"id": "image.generate", "label": "图片生成 4.6", "kind": "image", "engine": engine, "provider": provider, "reqKey": imageModel(getenv)}, + 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()}, }, nil } } -func seedanceCapabilityLimits() map[string]any { - return map[string]any{"durationSeconds": map[string]int{"min": 4, "max": 15}, "ratios": []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"}, "resolutions": []string{"480p", "720p", "1080p"}} +func seedanceCapabilityLimits(model string) map[string]any { + maximum := 15 + if model == "doubao-seedance-2-5-260628" { + maximum = 30 + } + return map[string]any{"durationSeconds": map[string]int{"min": 4, "max": maximum}, "ratios": []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"}, "resolutions": []string{"480p", "720p", "1080p"}} +} + +func seedanceModelOptions() []any { + return []any{ + map[string]any{"id": "doubao-seedance-2-0-260128", "label": "Seedance 2.0", "limits": seedanceCapabilityLimits("doubao-seedance-2-0-260128")}, + map[string]any{"id": "doubao-seedance-2-5-260628", "label": "Seedance 2.5", "limits": seedanceCapabilityLimits("doubao-seedance-2-5-260628")}, + } } func bailianVideoCapabilityLimits() map[string]any { @@ -254,7 +305,7 @@ func bailianVideoCapabilityLimits() map[string]any { func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails { image := imageEngine(getenv) - imageLabel := map[string]string{"jimeng": "即梦", "evolink": "EvoLink", "bailian": "阿里云百炼"}[image] + 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] visualConfigured := strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) != "" && strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) != "" @@ -274,12 +325,19 @@ func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails { BailianMode: providerMode(bailianKey != "", "bailian"), AuthMode: authMode, Capabilities: []any{ - map[string]any{"id": "image.generate", "label": "图片生成 4.6", "engine": image, "engineLabel": imageLabel, "reqKey": imageModel(getenv)}, - map[string]any{"id": "video.generate", "label": "视频生成", "engine": video, "engineLabel": videoLabel, "reqKey": videoModel(getenv)}, + 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()}, }, } } +func imageCapabilityLabel(engine string) string { + if engine == "seedream" { + return "Seedream 5.0 Pro 图片生成" + } + return "图片生成 4.6" +} + func providerMode(configured bool, live string) string { if !configured { return "missing" @@ -386,7 +444,7 @@ func configuredProviderJobBuilder(getenv postgres.Getenv, enforceAvailability bo return jobs.ProviderJobBuilder{ ImageProvider: imageProvider(getenv), VideoProvider: videoProvider(getenv), ImageModel: imageModel(getenv), VideoModel: videoModel(getenv), ImageEngine: imageEngine(getenv), VideoEngine: videoEngine(getenv), - ImageEngines: providerImageTargets(getenv), VideoEngines: providerVideoTargets(getenv), UnavailableProviders: unavailable, NewID: applicationJobID, + ImageEngines: providerImageTargets(getenv), VideoEngines: providerVideoTargets(getenv), VideoModels: providerVideoModels(getenv), UnavailableProviders: unavailable, NewID: applicationJobID, } } diff --git a/backend/internal/application/runtime_test.go b/backend/internal/application/runtime_test.go index 0e1bed7..f627b15 100644 --- a/backend/internal/application/runtime_test.go +++ b/backend/internal/application/runtime_test.go @@ -190,6 +190,31 @@ func TestRuntimeProviderJobBuilderRefreshesDatabaseSettings(t *testing.T) { } } +func TestRuntimeProviderJobBuilderRoutesSeedreamWithoutReplacingJimeng(t *testing.T) { + values := map[string]string{ + "SEEDANCE_API_KEY": "ark-secret", + "IMAGE_GENERATE_ENGINE": "seedream", + } + getenv := func(name string) string { return values[name] } + builder := configuredProviderJobBuilder(getenv, true) + command, err := builder.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{ + "prompt": "draw", "engine": "seedream", "settings": map[string]any{"size": "1.5K", "outputFormat": "png", "optimizeMode": "standard"}, + }) + if err != nil { + t.Fatal(err) + } + if command.Job.Provider != "seedream" || command.Job.ReqKey != providers.Seedream50ProModel { + t.Fatalf("job=%#v", command.Job) + } + jimeng, err := builder.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{"prompt": "draw", "engine": "jimeng"}) + if err == nil || !strings.Contains(err.Error(), "VOLCENGINE_ACCESS_KEY_ID") { + t.Fatalf("jimeng availability error=%v job=%#v", err, jimeng.Job) + } + if _, ok := buildProviderRegistryWithClient(getenv, http.DefaultClient, 2<<20)["seedream"].(*providers.Seedream); !ok { + t.Fatalf("seedream adapter is not registered") + } +} + func TestRuntimeProviderResolverUsesLatestDatabaseCredential(t *testing.T) { var authorizations []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { @@ -288,6 +313,22 @@ func TestProviderVideoTargetsUseDocumentedSeedanceDefaults(t *testing.T) { } } +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"} { + target, ok := targets[model] + if !ok || target.Provider != "seedance" || target.Model != model { + t.Fatalf("target[%q] = %#v", model, target) + } + } + if seedanceCapabilityLimits("doubao-seedance-2-0-260128")["durationSeconds"].(map[string]int)["max"] != 15 { + t.Fatal("Seedance 2.0 duration limit changed") + } + if seedanceCapabilityLimits("doubao-seedance-2-5-260628")["durationSeconds"].(map[string]int)["max"] != 30 { + t.Fatal("Seedance 2.5 duration limit is not 30 seconds") + } +} + func TestCapabilitySummaryMatchesConfiguredDefaultVideoEngine(t *testing.T) { for _, test := range []struct { name, engine, wantEngine, wantProvider, wantModel string diff --git a/backend/internal/billing/defaults.go b/backend/internal/billing/defaults.go index 3b67437..a6e7546 100644 --- a/backend/internal/billing/defaults.go +++ b/backend/internal/billing/defaults.go @@ -9,7 +9,7 @@ import ( const ( DefaultBillingMarkupMultiplier = 1.2 - BillingCatalogObservedAt = "2026-08-11" + BillingCatalogObservedAt = "2026-08-21" ) // PriceRuleSeeder is an optional store capability. Existing Store @@ -24,12 +24,16 @@ func DefaultBillingPriceRules() []PriceRule { {ID: "base-volcengine-jimeng-seedream46", Provider: "volcengine-visual", Capability: "image.generate", ReqKey: "jimeng_seedream46_cvtob", Unit: UnitImage, StandardUnitPriceFen: 20, Note: "即梦4.6公开资源包基准。", Source: catalogSource("https://www.volcengine.com/activity/jimeng")}, {ID: "base-evolink-gpt-image-2", Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2", Unit: UnitImage, StandardUnitPriceFen: 34, Note: "medium / 1K / 1:1 / 无参考图基准。", Source: catalogSource("https://evolink.ai/zh/gpt-image-2"), Dimensions: evolinkDimensions()}, {ID: "base-bailian-wan27-image-pro", Provider: "bailian", Capability: "image.generate", ReqKey: "wan2.7-image-pro", Unit: UnitImage, StandardUnitPriceFen: 50, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-image-pro")}, + {ID: "base-seedream-5-0-pro", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 30, Note: "基础生图:1K/1.5K ¥0.30,2K ¥0.60;首张输入图免费,额外输入图 ¥0.02/张。", Source: catalogSource("https://docs.volcengine.com/docs/82379/1544106?lang=zh"), Dimensions: seedreamDimensions()}, {ID: "base-bailian-wan27-i2v-720p", Provider: "bailian", Capability: "video.generate", ReqKey: "wan2.7-i2v-2026-04-25", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 60, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-i2v")}, {ID: "base-bailian-wan27-i2v-1080p", Provider: "bailian", Capability: "video.generate", ReqKey: "wan2.7-i2v-2026-04-25", VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 100, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-i2v")}, {ID: "base-seedance-2-0-480p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=480p", Unit: UnitVideoSecond, StandardUnitPriceFen: 46, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")}, {ID: "base-seedance-2-0-720p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 99, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")}, {ID: "base-seedance-2-0-1080p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 248, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")}, {ID: "base-seedance-2-0-4k", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=4k", Unit: UnitVideoSecond, StandardUnitPriceFen: 505, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")}, + {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")}, } for i := range rules { rules[i].Enabled = true @@ -38,6 +42,17 @@ func DefaultBillingPriceRules() []PriceRule { return rules } +func seedreamDimensions() []ParameterDimension { + return []ParameterDimension{{ + Key: "size", Label: "分辨率", BaselineValue: "1.5K", DefaultValue: "1.5K", + Tiers: []ParameterTier{ + {Value: "1K", Label: "1K", StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}, + {Value: "1.5K", Label: "1.5K", StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}, + {Value: "2K", Label: "2K", StandardFactor: 2, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}, + }, + }} +} + func catalogSource(url string) map[string]any { return map[string]any{"url": url, "observedAt": BillingCatalogObservedAt} } @@ -81,12 +96,14 @@ func NormalizeBillingParameters(payload map[string]any, fallback Parameters) Par setText(out, "resolution", first(settings["resolution"], providerParameters["resolution"], providerPayload["resolution"], inputSettings["resolution"], input["resolution"])) setText(out, "quality", first(input["quality"], settings["quality"], providerPayload["quality"], providerParameters["quality"])) setNumber(out, "duration", first(settings["duration"], providerParameters["duration"], providerPayload["duration"], inputSettings["duration"], input["duration"]), false) - setNumber(out, "imageCount", first(providerPayload["n"], providerParameters["n"], input["n"], input["imageCount"]), true) + setNumber(out, "imageCount", first(settings["imageCount"], providerPayload["n"], providerParameters["n"], input["n"], input["imageCount"]), true) setNumber(out, "scale", first(input["scale"], settings["scale"]), false) + setText(out, "outputFormat", first(settings["outputFormat"], providerPayload["output_format"], inputSettings["outputFormat"], input["outputFormat"])) + setText(out, "optimizeMode", first(settings["optimizeMode"], record(providerPayload["optimize_prompt_options"])["mode"], inputSettings["optimizeMode"], input["optimizeMode"])) width, widthOK := finiteNumber(first(providerPayload["width"], input["width"])) height, heightOK := finiteNumber(first(providerPayload["height"], input["height"])) - if size := normalizedText(first(providerPayload["size"], providerParameters["size"], inputSettings["size"], input["size"])); size != "" { + if size := normalizedText(first(settings["size"], providerPayload["size"], providerParameters["size"], inputSettings["size"], input["size"])); size != "" { out["size"] = strings.ReplaceAll(size, "×", "*") } else if widthOK && heightOK { out["size"] = fmt.Sprintf("%g*%g", width, height) @@ -164,13 +181,23 @@ func aspectRatio(width, height float64) string { return fmt.Sprintf("%g:%g", width, height) } func referenceCount(payload, input, providerPayload map[string]any) int { - count := 0 for _, value := range []any{payload["imageUrls"], payload["inputUrls"], input["imageUrls"], providerPayload["image_urls"], providerPayload["imageUrls"]} { switch items := value.(type) { case []any: - count += len(items) + if len(items) > 0 { + return len(items) + } case []string: - count += len(items) + if len(items) > 0 { + return len(items) + } + } + } + count := 0 + for _, material := range billingMaterials(payload) { + materialType := strings.ToLower(strings.TrimSpace(fmt.Sprint(material["type"]))) + if materialType == "" || materialType == "image" { + count++ } } return count diff --git a/backend/internal/billing/defaults_test.go b/backend/internal/billing/defaults_test.go index 2007609..0c33492 100644 --- a/backend/internal/billing/defaults_test.go +++ b/backend/internal/billing/defaults_test.go @@ -8,12 +8,16 @@ func TestDefaultBillingPriceRulesFreezeOfficialCatalogMatchKeys(t *testing.T) { "base-volcengine-jimeng-seedream46": false, "base-evolink-gpt-image-2": false, "base-bailian-wan27-image-pro": false, + "base-seedream-5-0-pro": false, "base-bailian-wan27-i2v-720p": false, "base-bailian-wan27-i2v-1080p": false, "base-seedance-2-0-480p": false, "base-seedance-2-0-720p": false, "base-seedance-2-0-1080p": false, "base-seedance-2-0-4k": false, + "base-seedance-2-5-480p": false, + "base-seedance-2-5-720p": false, + "base-seedance-2-5-1080p": false, } for _, rule := range rules { if _, ok := want[rule.ID]; !ok { diff --git a/backend/internal/billing/seedream.go b/backend/internal/billing/seedream.go new file mode 100644 index 0000000..48d4887 --- /dev/null +++ b/backend/internal/billing/seedream.go @@ -0,0 +1,30 @@ +package billing + +import ( + "errors" + "math" + "strings" +) + +const Seedream50ProModel = "doubao-seedream-5-0-pro-260628" + +// CalculateSeedreamAmountFen applies Seedream's additive input-image price +// before the platform markup: the first reference image is free and every +// additional reference image costs 2 fen. +func CalculateSeedreamAmountFen(outputPriceFen int64, referenceImageCount int, markupMultiplier float64) (int64, error) { + if outputPriceFen <= 0 { + return 0, errors.New("seedream output price must be positive") + } + if referenceImageCount < 0 || referenceImageCount > 10 { + return 0, errors.New("seedream reference image count must be between 0 and 10") + } + if math.IsNaN(markupMultiplier) || math.IsInf(markupMultiplier, 0) || markupMultiplier < 1 { + return 0, errors.New("seedream markup multiplier must be at least 1") + } + extraInputPriceFen := int64(max(0, referenceImageCount-1) * 2) + return int64(math.Ceil(float64(outputPriceFen+extraInputPriceFen) * markupMultiplier)), nil +} + +func IsSeedreamBillingModel(model string) bool { + return strings.TrimSpace(model) == Seedream50ProModel +} diff --git a/backend/internal/billing/service.go b/backend/internal/billing/service.go index c8501ee..1d33e60 100644 --- a/backend/internal/billing/service.go +++ b/backend/internal/billing/service.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "math" "strings" "time" ) @@ -77,11 +78,32 @@ func (s *Service) Quote(ctx context.Context, command QuoteCommand) (*Quote, erro } parameters := NormalizeBillingParameters(command.Payload, command.Parameters) quote, err := (Catalog{Rules: rules}).Quote(QuoteInput{Provider: command.Provider, Capability: command.Capability, ReqKey: command.ReqKey, Source: "platform", Role: command.Role, Parameters: parameters}) - if err != nil || quote == nil || command.Provider != "seedance" || command.ReqKey != "doubao-seedance-2-0-260128" { + if err != nil || quote == nil { return quote, err } - inputVideo, inputDuration := seedanceInputVideo(command.Payload) + if command.Provider == "seedream" && IsSeedreamBillingModel(command.ReqKey) { + referenceImageCount := 0 + if count, ok := number(parameters["referenceImageCount"]); ok { + referenceImageCount = int(math.Ceil(count)) + } + amount, calculateErr := CalculateSeedreamAmountFen(quote.StandardUnitPriceFen, referenceImageCount, quote.MarkupMultiplier) + if calculateErr != nil { + return nil, calculateErr + } + quote.AmountFen = amount + if quote.Parameters == nil { + quote.Parameters = Parameters{} + } + quote.Parameters["referenceImageCount"] = float64(referenceImageCount) + quote.Parameters["inputReferencePriceFen"] = float64(max(0, referenceImageCount-1) * 2) + return quote, nil + } + if command.Provider != "seedance" || !IsSeedanceBillingModel(command.ReqKey) { + return quote, nil + } + inputVideo, inputDuration := seedanceInputVideo(command.Payload, command.ReqKey) estimated, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{ + Model: command.ReqKey, Resolution: fmt.Sprint(parameters["resolution"]), AspectRatio: fmt.Sprint(parameters["aspectRatio"]), OutputDurationSeconds: quote.Quantity, InputVideo: inputVideo, InputVideoDurationSeconds: inputDuration, MarkupMultiplier: quote.MarkupMultiplier, diff --git a/backend/internal/billing/service_test.go b/backend/internal/billing/service_test.go index 1fc6b8e..5c67837 100644 --- a/backend/internal/billing/service_test.go +++ b/backend/internal/billing/service_test.go @@ -78,20 +78,92 @@ func TestServiceQuoteFreezesConservativeSeedanceReserve(t *testing.T) { } } +func TestServiceQuoteUsesSeedance25CatalogAndTokenEstimate(t *testing.T) { + store := "eStoreStub{rules: []PriceRule{{ + ID: "seedance-25-720", Provider: "seedance", Capability: "video.generate", + ReqKey: Seedance25Model, VariantKey: "resolution=720p", Unit: UnitVideoSecond, + StandardUnitPriceFen: 151, MarkupMultiplier: 1.2, Enabled: true, + }}} + quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{ + Provider: "seedance", Capability: "video.generate", ReqKey: Seedance25Model, + Payload: map[string]any{"settings": map[string]any{"duration": 5.0, "resolution": "720p", "ratio": "16:9"}}, + }) + if err != nil { + t.Fatal(err) + } + if quote.AmountFen != 908 || quote.ReservedAmountFen != 908 || quote.MarkupMultiplier != 1.2 { + t.Fatalf("quote = %#v", quote) + } +} + +func TestServiceQuoteAppliesSeedreamSizeAndReferenceInputPricing(t *testing.T) { + store := "eStoreStub{rules: []PriceRule{{ + ID: "seedream-5", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, + Unit: UnitImage, StandardUnitPriceFen: 30, MarkupMultiplier: 1.2, Enabled: true, Dimensions: seedreamDimensions(), + }}} + quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{ + Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, + Payload: map[string]any{ + "settings": map[string]any{"size": "2K", "imageCount": 1.0}, + "materials": []any{ + map[string]any{"type": "image", "url": "https://in.test/1.png"}, + map[string]any{"type": "image", "url": "https://in.test/2.png"}, + map[string]any{"type": "image", "url": "https://in.test/3.png"}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + // 2K output = 60 fen, two billable references = 4 fen, then 1.2x. + if quote.StandardUnitPriceFen != 60 || quote.AmountFen != 77 || quote.Parameters["referenceImageCount"] != float64(3) || quote.Parameters["inputReferencePriceFen"] != float64(4) { + t.Fatalf("quote=%#v", quote) + } +} + +func TestCalculateSeedreamAmountFenOfficialVectors(t *testing.T) { + tests := []struct { + output, references int + want int64 + }{{30, 0, 36}, {30, 1, 36}, {30, 3, 41}, {60, 3, 77}, {60, 10, 94}} + for _, test := range tests { + got, err := CalculateSeedreamAmountFen(int64(test.output), test.references, 1.2) + if err != nil || got != test.want { + t.Fatalf("output=%d refs=%d got=%d err=%v want=%d", test.output, test.references, got, err, test.want) + } + } +} + func TestSeedanceEstimateMatchesExecutableTypeScriptVectors(t *testing.T) { withoutVideo, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{ + Model: Seedance20Model, Resolution: "720p", AspectRatio: "16:9", OutputDurationSeconds: 5, MarkupMultiplier: 1.2, }) if err != nil || withoutVideo != 597 { t.Fatalf("without video = %d, %v", withoutVideo, err) } withUnknownVideo, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{ + Model: Seedance20Model, Resolution: "720p", AspectRatio: "9:16", OutputDurationSeconds: 5, InputVideo: true, MarkupMultiplier: 1.2, }) if err != nil || withUnknownVideo != 1452 { t.Fatalf("with video = %d, %v", withUnknownVideo, err) } + seedance25, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{ + Model: Seedance25Model, Resolution: "720p", AspectRatio: "16:9", OutputDurationSeconds: 5, + MarkupMultiplier: 1.2, + }) + if err != nil || seedance25 != 908 { + t.Fatalf("seedance 2.5 without video = %d, %v", seedance25, err) + } + seedance25WithShortVideo, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{ + Model: Seedance25Model, Resolution: "720p", AspectRatio: "16:9", OutputDurationSeconds: 5, + InputVideo: true, InputVideoDurationSeconds: 2, MarkupMultiplier: 1.2, + }) + if err != nil || seedance25WithShortVideo != 980 { + t.Fatalf("seedance 2.5 with short video = %d, %v", seedance25WithShortVideo, err) + } } type quoteStoreStub struct { diff --git a/backend/internal/billing/settlement.go b/backend/internal/billing/settlement.go index 49f7ea3..65d394e 100644 --- a/backend/internal/billing/settlement.go +++ b/backend/internal/billing/settlement.go @@ -12,11 +12,13 @@ import ( const seedanceTokenScale = 1_000_000 const ( - seedanceFPS = 24 - seedanceMaximumInputDuration = 15 + Seedance20Model = "doubao-seedance-2-0-260128" + Seedance25Model = "doubao-seedance-2-5-260628" + seedanceFPS = 24 ) type SeedanceEstimateInput struct { + Model string Resolution, AspectRatio string OutputDurationSeconds float64 InputVideoDurationSeconds float64 @@ -25,24 +27,34 @@ type SeedanceEstimateInput struct { } type SeedanceActualAmountInput struct { + Model string Resolution string InputVideo bool CompletionTokens int64 MarkupMultiplier float64 } -func SeedanceTokenPriceFenPerMillion(resolution string, inputVideo bool) int64 { +func SeedanceTokenPriceFenPerMillion(model, resolution string, inputVideo bool) int64 { normalized := strings.ToLower(strings.TrimSpace(resolution)) var withoutVideo, withVideo int64 - switch normalized { - case "480p", "720p": - withoutVideo, withVideo = 4600, 2800 - case "1080p": - withoutVideo, withVideo = 5100, 3100 - case "4k": - withoutVideo, withVideo = 2600, 1600 - default: - withoutVideo, withVideo = 4600, 2800 + if strings.TrimSpace(model) == Seedance25Model { + switch normalized { + case "1080p": + withoutVideo, withVideo = 7700, 4600 + default: + withoutVideo, withVideo = 7000, 4200 + } + } else { + switch normalized { + case "480p", "720p": + withoutVideo, withVideo = 4600, 2800 + case "1080p": + withoutVideo, withVideo = 5100, 3100 + case "4k": + withoutVideo, withVideo = 2600, 1600 + default: + withoutVideo, withVideo = 4600, 2800 + } } if inputVideo { return withVideo @@ -57,7 +69,7 @@ func CalculateSeedanceActualAmountFen(input SeedanceActualAmountInput) (int64, e if math.IsNaN(input.MarkupMultiplier) || math.IsInf(input.MarkupMultiplier, 0) || input.MarkupMultiplier < 1 { return 0, errors.New("seedance markup multiplier must be at least 1") } - amount := math.Ceil(float64(input.CompletionTokens) * float64(SeedanceTokenPriceFenPerMillion(input.Resolution, input.InputVideo)) * input.MarkupMultiplier / seedanceTokenScale) + amount := math.Ceil(float64(input.CompletionTokens) * float64(SeedanceTokenPriceFenPerMillion(input.Model, input.Resolution, input.InputVideo)) * input.MarkupMultiplier / seedanceTokenScale) if amount < 1 { amount = 1 } @@ -75,13 +87,13 @@ func EstimateSeedanceAmountFen(input SeedanceEstimateInput) (int64, error) { if input.InputVideo { inputDuration = input.InputVideoDurationSeconds if math.IsNaN(inputDuration) || math.IsInf(inputDuration, 0) || inputDuration <= 0 { - inputDuration = seedanceMaximumInputDuration + inputDuration = seedanceMaximumInputDuration(input.Model) } - inputDuration = math.Min(seedanceMaximumInputDuration, inputDuration) + inputDuration = normalizeSeedanceInputDuration(input.Model, inputDuration) } width, height := seedanceOutputDimensions(input.Resolution, input.AspectRatio) tokens := math.Ceil((inputDuration + input.OutputDurationSeconds) * float64(width*height*seedanceFPS) / 1024) - amount := math.Ceil(tokens * float64(SeedanceTokenPriceFenPerMillion(input.Resolution, input.InputVideo)) * input.MarkupMultiplier / seedanceTokenScale) + amount := math.Ceil(tokens * float64(SeedanceTokenPriceFenPerMillion(input.Model, input.Resolution, input.InputVideo)) * input.MarkupMultiplier / seedanceTokenScale) return int64(math.Max(1, amount)), nil } @@ -108,7 +120,7 @@ func seedanceOutputDimensions(resolution, aspectRatio string) (int, int) { return max(1, int(math.Round(math.Sqrt(area*ratio)))), max(1, int(math.Round(math.Sqrt(area/ratio)))) } -func seedanceInputVideo(payload map[string]any) (bool, float64) { +func seedanceInputVideo(payload map[string]any, model string) (bool, float64) { materials := billingMaterials(payload) total, known := 0.0, false for _, material := range materials { @@ -136,7 +148,26 @@ func seedanceInputVideo(payload map[string]any) (bool, float64) { if !known { return inputVideo, 0 } - return inputVideo, math.Min(seedanceMaximumInputDuration, total) + return inputVideo, normalizeSeedanceInputDuration(model, total) +} + +func IsSeedanceBillingModel(model string) bool { + model = strings.TrimSpace(model) + return model == Seedance20Model || model == Seedance25Model +} + +func seedanceMaximumInputDuration(model string) float64 { + if strings.TrimSpace(model) == Seedance25Model { + return 30 + } + return 15 +} + +func normalizeSeedanceInputDuration(model string, duration float64) float64 { + if strings.TrimSpace(model) == Seedance25Model && duration < 4 { + duration = 4 + } + return math.Min(seedanceMaximumInputDuration(model), duration) } func billingMaterials(payload map[string]any) []map[string]any { diff --git a/backend/internal/billing/settlement_test.go b/backend/internal/billing/settlement_test.go index 79f64e7..6baf2a9 100644 --- a/backend/internal/billing/settlement_test.go +++ b/backend/internal/billing/settlement_test.go @@ -8,20 +8,23 @@ import ( func TestSeedanceActualAmountUsesFrozenTokenPricingAndRoundsUp(t *testing.T) { tests := []struct { + model string resolution string inputVideo bool wantPrice int64 wantAmount int64 }{ - {resolution: "720P", wantPrice: 4600, wantAmount: 6}, - {resolution: "1080p", inputVideo: true, wantPrice: 3100, wantAmount: 4}, - {resolution: "4K", wantPrice: 2600, wantAmount: 4}, + {model: Seedance20Model, resolution: "720P", wantPrice: 4600, wantAmount: 6}, + {model: Seedance20Model, resolution: "1080p", inputVideo: true, wantPrice: 3100, wantAmount: 4}, + {model: Seedance20Model, resolution: "4K", wantPrice: 2600, wantAmount: 4}, + {model: Seedance25Model, resolution: "720P", wantPrice: 7000, wantAmount: 9}, + {model: Seedance25Model, resolution: "1080p", inputVideo: true, wantPrice: 4600, wantAmount: 6}, } for _, test := range tests { - if got := SeedanceTokenPriceFenPerMillion(test.resolution, test.inputVideo); got != test.wantPrice { + if got := SeedanceTokenPriceFenPerMillion(test.model, test.resolution, test.inputVideo); got != test.wantPrice { t.Fatalf("price(%q, %v) = %d, want %d", test.resolution, test.inputVideo, got, test.wantPrice) } - got, err := CalculateSeedanceActualAmountFen(SeedanceActualAmountInput{Resolution: test.resolution, InputVideo: test.inputVideo, CompletionTokens: 1001, MarkupMultiplier: 1.2}) + got, err := CalculateSeedanceActualAmountFen(SeedanceActualAmountInput{Model: test.model, Resolution: test.resolution, InputVideo: test.inputVideo, CompletionTokens: 1001, MarkupMultiplier: 1.2}) if err != nil || got != test.wantAmount { t.Fatalf("amount(%q, %v) = %d, %v; want %d", test.resolution, test.inputVideo, got, err, test.wantAmount) } diff --git a/backend/internal/httpapi/openapi.go b/backend/internal/httpapi/openapi.go index a92f358..0366a72 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", "bailian"}}, + "provider": map[string]any{"type": "string", "enum": []string{"volcengine-visual", "evolink", "seedance", "seedream", "bailian"}}, "reqKey": map[string]any{"type": "string"}, "status": openAPIRef("GenerationStatus"), "prompt": map[string]any{"type": "string"}, @@ -94,17 +94,22 @@ 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"}}, + "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 image.generate unless promptAssembly is supplied."}, "inputUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Reference image URLs for image capabilities."}, "imageUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Alias for image input URLs."}, "inputAssetIds": openAPIStringArray(), "materials": map[string]any{"type": "array", "items": openAPIRef("PromptMaterial")}, "settings": map[string]any{ - "type": "object", "description": "Video settings for video.generate.", + "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": 15}, - "resolution": map[string]any{"type": "string", "enum": []string{"480p", "720p", "1080p"}}, + "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"}}, + "size": map[string]any{"type": "string", "enum": []string{"1K", "1.5K", "2K"}, "description": "Seedream 5.0 Pro output size."}, + "outputFormat": map[string]any{"type": "string", "enum": []string{"png", "jpeg"}}, + "optimizeMode": map[string]any{"type": "string", "enum": []string{"standard", "fast"}}, }, }, "width": map[string]any{"type": "integer", "example": 1440}, @@ -236,6 +241,7 @@ func openAPIPaths() map[string]any { }}, "videoGenerate": map[string]any{"summary": "Video generation", "value": map[string]any{ "capability": "video.generate", "prompt": "生成一条 9:16 品牌短视频", + "model": "doubao-seedance-2-5-260628", "settings": map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}, }}, }, diff --git a/backend/internal/httpapi/usage.go b/backend/internal/httpapi/usage.go index 11ab97e..435044c 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 != "bailian" { + if provider != "" && provider != "volcengine-visual" && provider != "evolink" && provider != "seedance" && provider != "seedream" && provider != "bailian" { writeAPIError(w, 400, "不支持的服务商。") return } diff --git a/backend/internal/jobs/provider.go b/backend/internal/jobs/provider.go index f10c74d..086118c 100644 --- a/backend/internal/jobs/provider.go +++ b/backend/internal/jobs/provider.go @@ -262,13 +262,14 @@ type ProviderJobBuilder struct { ImageEngine, VideoEngine string ImageEngines map[string]ProviderTarget VideoEngines map[string]ProviderTarget + VideoModels map[string]ProviderTarget UnavailableProviders map[string]string NewID func() string } // ProviderTarget is server-owned routing configuration for one UI engine. -// The request may select a known engine, but it cannot supply a provider or -// model directly. +// The request may select a known engine and an allow-listed model, but it +// cannot supply an arbitrary provider or model identifier. type ProviderTarget struct { Provider string Model string @@ -279,7 +280,7 @@ func (b ProviderJobBuilder) Build(_ context.Context, owner, client, capability, if capability != "image.generate" && capability != "video.generate" { return CreateCommand{}, &Error{Kind: ErrorInvalid, Status: 400, Message: "Unsupported capability: " + capability} } - target, engine, err := b.target(capability, body["engine"]) + target, engine, err := b.target(capability, body["engine"], body["model"]) if err != nil { return CreateCommand{}, err } @@ -289,7 +290,7 @@ func (b ProviderJobBuilder) Build(_ context.Context, owner, client, capability, if target.Provider == "" || target.Model == "" || b.NewID == nil { return CreateCommand{}, errors.New("provider job builder is not configured") } - prepared, err := prepareProviderRequest(capability, engine, target.Settings, body) + prepared, err := prepareProviderRequest(capability, engine, target.Model, target.Settings, body) if err != nil { return CreateCommand{}, err } @@ -312,14 +313,14 @@ func validWebhookURL(value string) bool { return err == nil && parsed.IsAbs() && parsed.Host != "" && parsed.User == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") } -func (b ProviderJobBuilder) target(capability string, rawEngine any) (ProviderTarget, string, error) { +func (b ProviderJobBuilder) target(capability string, rawEngine, rawModel any) (ProviderTarget, string, error) { engine := engineName(rawEngine) if capability == "image.generate" { if rawEngine != nil && engine == "" { return ProviderTarget{}, "", invalidPreparation("unsupported image engine") } if engine != "" { - if engine != "jimeng" && engine != "evolink" && engine != "bailian" { + if engine != "jimeng" && engine != "evolink" && engine != "bailian" && engine != "seedream" { return ProviderTarget{}, "", invalidPreparation("unsupported image engine") } target, ok := b.ImageEngines[engine] @@ -337,6 +338,21 @@ func (b ProviderJobBuilder) target(capability string, rawEngine any) (ProviderTa if rawEngine != nil && engine == "" { return ProviderTarget{}, "", invalidPreparation("unsupported video engine") } + configured := firstConfiguredEngine(b.VideoEngine, b.VideoProvider, "video") + if engine == "" { + engine = configured + } + if rawModel != nil { + model := stringValue(rawModel) + if model == "" || engine != "seedance" { + return ProviderTarget{}, "", invalidPreparation("unsupported video model") + } + target, ok := b.VideoModels[model] + if !ok || target.Provider != "seedance" || target.Model == "" { + return ProviderTarget{}, "", invalidPreparation("unsupported video model") + } + return target, engine, nil + } if engine != "" { if engine != "seedance" && engine != "bailian" { return ProviderTarget{}, "", invalidPreparation("unsupported video engine") @@ -347,7 +363,6 @@ func (b ProviderJobBuilder) target(capability string, rawEngine any) (ProviderTa } return target, engine, nil } - configured := firstConfiguredEngine(b.VideoEngine, b.VideoProvider, "video") if target, ok := b.VideoEngines[configured]; ok && target.Provider == b.VideoProvider && target.Model == b.VideoModel { return target, configured, nil } @@ -362,7 +377,7 @@ func firstConfiguredEngine(configured, provider, capabilityKind string) string { switch provider { case "volcengine-visual": return "jimeng" - case "evolink", "bailian": + case "evolink", "bailian", "seedream": return provider } } else if provider == "seedance" || provider == "bailian" { @@ -376,7 +391,7 @@ type preparedProviderRequest struct { assetIDs []string } -func prepareProviderRequest(capability, engine string, defaults map[string]any, body map[string]any) (preparedProviderRequest, error) { +func prepareProviderRequest(capability, engine, model string, defaults map[string]any, body map[string]any) (preparedProviderRequest, error) { materials, assembly, err := preparationMaterials(body, capability) if err != nil { return preparedProviderRequest{}, err @@ -418,6 +433,9 @@ func prepareProviderRequest(capability, engine string, defaults map[string]any, if err == nil && engine == "bailian" { err = validateBailianImage(urls, settings) } + if err == nil && engine == "seedream" { + err = validateSeedreamImage(urls, materials, settings) + } } else { if err := validateMaterialCoverage(text, materials); err != nil { return preparedProviderRequest{}, err @@ -425,7 +443,7 @@ func prepareProviderRequest(capability, engine string, defaults map[string]any, for _, material := range materials { urls = append(urls, material.URL) } - settings, err = videoSettings(engine, defaults, body["settings"], materials) + settings, err = videoSettings(engine, model, defaults, body["settings"], materials) } if err != nil { return preparedProviderRequest{}, err @@ -447,13 +465,10 @@ func prepareProviderRequest(capability, engine string, defaults map[string]any, } func applyImageSettingDefaults(settings, defaults map[string]any) { - if _, configured := settings["quality"]; configured { - return - } - raw, _ := defaults["quality"].(string) - quality := strings.ToLower(strings.TrimSpace(raw)) - if quality == "low" || quality == "medium" || quality == "high" { - settings["quality"] = quality + for key, value := range defaults { + if _, configured := settings[key]; !configured { + settings[key] = value + } } } @@ -526,7 +541,7 @@ func validateMaterialCoverage(text string, materials []prompt.Material) error { func imageSettings(body map[string]any) (map[string]any, error) { out := map[string]any{} if nested, ok := body["settings"].(map[string]any); ok { - for _, key := range []string{"scale", "width", "height", "min_ratio", "max_ratio", "imageCount", "force_single", "quality"} { + for _, key := range []string{"scale", "width", "height", "min_ratio", "max_ratio", "imageCount", "force_single", "quality", "size", "outputFormat", "optimizeMode"} { if nested[key] != nil { out[key] = nested[key] } @@ -567,12 +582,61 @@ func imageSettings(body map[string]any) (map[string]any, error) { out["quality"] = quality } } + for _, key := range []string{"size", "outputFormat", "optimizeMode"} { + raw := body[key] + if raw == nil { + raw = out[key] + } + delete(out, key) + if raw == nil || raw == "" { + continue + } + value, ok := raw.(string) + if !ok || strings.TrimSpace(value) == "" { + return nil, invalidPreparation("invalid image parameter: " + key) + } + out[key] = strings.TrimSpace(value) + } if count, ok := out["imageCount"].(float64); ok && (count <= 0 || count > 9 || math.Trunc(count) != count) { return nil, invalidPreparation("imageCount must be an integer between 1 and 9") } return out, nil } +func validateSeedreamImage(urls []string, materials []prompt.Material, settings map[string]any) error { + if len(urls) > 10 { + return invalidPreparation("seedream supports at most 10 reference images") + } + for _, material := range materials { + if material.Type != "image" { + return invalidPreparation("seedream supports image materials only") + } + } + if settings["width"] != nil || settings["height"] != nil { + return invalidPreparation("seedream requires a size preset instead of width and height") + } + size := strings.ToUpper(strings.TrimSpace(stringValue(settings["size"]))) + if size != "1K" && size != "1.5K" && size != "2K" { + return invalidPreparation("seedream size must be 1K, 1.5K, or 2K") + } + format := strings.ToLower(strings.TrimSpace(stringValue(settings["outputFormat"]))) + if format != "png" && format != "jpeg" { + return invalidPreparation("seedream output format must be png or jpeg") + } + mode := strings.ToLower(strings.TrimSpace(stringValue(settings["optimizeMode"]))) + if mode != "standard" && mode != "fast" { + return invalidPreparation("seedream prompt optimization mode must be standard or fast") + } + if count, ok := finiteNumber(settings["imageCount"]); ok && count != 1 { + return invalidPreparation("seedream basic generation supports one output image") + } + settings["size"] = size + settings["outputFormat"] = format + settings["optimizeMode"] = mode + settings["imageCount"] = float64(1) + return nil +} + func validateBailianImage(urls []string, settings map[string]any) error { if len(urls) > 9 { return invalidPreparation("bailian supports at most 9 reference images") @@ -596,7 +660,12 @@ func validateBailianImage(urls []string, settings map[string]any) error { return nil } -func videoSettings(engine string, defaults map[string]any, raw any, materials []prompt.Material) (map[string]any, error) { +const ( + seedance20Model = "doubao-seedance-2-0-260128" + seedance25Model = "doubao-seedance-2-5-260628" +) + +func videoSettings(engine, model string, defaults map[string]any, raw any, materials []prompt.Material) (map[string]any, error) { input, _ := raw.(map[string]any) merged := make(map[string]any, len(defaults)+len(input)) for key, value := range defaults { @@ -636,8 +705,16 @@ func videoSettings(engine string, defaults map[string]any, raw any, materials [] if engine != "" && engine != "seedance" { return nil, invalidPreparation("unsupported video engine") } - if len(materials) > 4 { - return nil, invalidPreparation("seedance supports at most 4 materials") + if model == seedance25Model { + counts := map[string]int{"image": 0, "video": 0, "audio": 0} + for _, material := range materials { + counts[material.Type]++ + } + if len(materials) > 50 || counts["image"] > 30 || counts["video"] > 10 || counts["audio"] > 10 { + return nil, invalidPreparation("seedance 2.5 supports at most 30 image, 10 video, and 10 audio materials") + } + } else if len(materials) > 4 { + return nil, invalidPreparation("seedance 2.0 supports at most 4 materials") } ratio := stringValue(input["ratio"]) if ratio != "" && !oneOf(ratio, "9:16", "16:9", "1:1", "4:3", "3:4", "21:9", "adaptive") { @@ -648,8 +725,12 @@ func videoSettings(engine string, defaults map[string]any, raw any, materials [] } if input["duration"] != nil { duration, ok := finiteNumber(input["duration"]) - if !ok || math.Trunc(duration) != duration || duration < 4 || duration > 15 { - return nil, invalidPreparation("video duration must be an integer between 4 and 15 seconds") + maximum := float64(15) + if model == seedance25Model { + maximum = 30 + } + if !ok || math.Trunc(duration) != duration || duration < 4 || duration > maximum { + return nil, invalidPreparation(fmt.Sprintf("video duration must be an integer between 4 and %.0f seconds", maximum)) } settings["duration"] = duration } diff --git a/backend/internal/jobs/provider_test.go b/backend/internal/jobs/provider_test.go index 2ef9d80..4809e2f 100644 --- a/backend/internal/jobs/provider_test.go +++ b/backend/internal/jobs/provider_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "log" "net/http" "net/http/httptest" @@ -78,6 +79,28 @@ func TestProviderJobBuilderUsesPublicInputURLsForImageMaterialCoverage(t *testin } } +func TestProviderJobBuilderPreparesSeedreamImageRequest(t *testing.T) { + b := testProviderBuilder() + cmd, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{ + "engine": "seedream", "prompt": "use @图片2", + "materials": []any{ + map[string]any{"id": "image-1", "url": "https://in.test/one.png", "type": "image"}, + map[string]any{"id": "image-2", "url": "https://in.test/two.png", "type": "image"}, + }, + "settings": map[string]any{"size": "2k", "outputFormat": "JPEG", "optimizeMode": "fast"}, + }) + if err != nil { + t.Fatal(err) + } + var request providers.Request + if err := json.Unmarshal(cmd.Job.RequestPayload, &request); err != nil { + t.Fatal(err) + } + if cmd.Job.Provider != "seedream" || cmd.Job.ReqKey != providers.Seedream50ProModel || request.Settings["size"] != "2K" || request.Settings["outputFormat"] != "jpeg" || request.Settings["optimizeMode"] != "fast" || request.Settings["imageCount"] != float64(1) || len(request.InputURLs) != 2 { + t.Fatalf("job=%#v request=%#v", cmd.Job, request) + } +} + func TestProviderJobBuilderPreparesSeedanceVideoRequest(t *testing.T) { b := testProviderBuilder() body := map[string]any{ @@ -124,6 +147,28 @@ func TestProviderJobBuilderAppliesInjectedVideoDefaults(t *testing.T) { } } +func TestProviderJobBuilderSelectsAllowListedSeedance25Model(t *testing.T) { + b := testProviderBuilder() + materials := []any{} + for index := 0; index < 5; index++ { + materials = append(materials, map[string]any{"url": fmt.Sprintf("https://in.test/%d.png", index), "type": "image"}) + } + cmd, err := b.Build(context.Background(), "owner", "", "video.generate", "", map[string]any{ + "engine": "seedance", "model": seedance25Model, "prompt": "video", "materials": materials, + "settings": map[string]any{"duration": 30.0, "resolution": "720p", "ratio": "16:9"}, + }) + if err != nil { + t.Fatal(err) + } + var request providers.Request + if err := json.Unmarshal(cmd.Job.RequestPayload, &request); err != nil { + t.Fatal(err) + } + if cmd.Job.ReqKey != seedance25Model || request.Model != seedance25Model || request.Settings["duration"] != float64(30) || len(request.Materials) != 5 { + t.Fatalf("job/request = %#v / %#v", cmd.Job, request) + } +} + func TestProviderJobBuilderPreparesBailianFirstAndLastFrameVideo(t *testing.T) { b := testProviderBuilder() body := map[string]any{ @@ -162,10 +207,16 @@ func TestProviderJobBuilderRejectsInvalidPreparation(t *testing.T) { {name: "arbitrary provider", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "attacker-provider"}, message: "unsupported image engine"}, {name: "bailian too many references", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "bailian", "imageUrls": tenURLs()}, message: "at most 9 reference images"}, {name: "bailian invalid image pixels", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "bailian", "width": 100.0, "height": 100.0}, message: "image dimensions"}, + {name: "seedream too many references", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "seedream", "imageUrls": append(tenURLs(), "https://in.test/11.png")}, message: "at most 10 reference images"}, + {name: "seedream rejects dimensions", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "seedream", "width": 1024.0, "height": 1024.0}, message: "size preset"}, + {name: "seedream rejects invalid format", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "seedream", "settings": map[string]any{"outputFormat": "webp"}}, message: "png or jpeg"}, {name: "seedance missing materials referenced by prompt", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "use @图片2"}, message: "requires @图片2"}, {name: "bailian requires frame", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{}}, message: "1 or 2 image materials"}, {name: "bailian rejects video material", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/a.mp4", "type": "video"}}}, message: "1 or 2 image materials"}, {name: "bad seedance settings", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "settings": map[string]any{"duration": 99.0}}, message: "video duration"}, + {name: "unknown seedance model", capability: "video.generate", body: map[string]any{"engine": "seedance", "model": "attacker-model", "prompt": "p"}, message: "unsupported video model"}, + {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: "seedance too many materials", capability: "video.generate", body: map[string]any{ "engine": "seedance", "prompt": "p", "materials": []any{ @@ -492,14 +543,19 @@ func testProviderBuilder() ProviderJobBuilder { VideoProvider: "video-default", VideoModel: "video-default-model", ImageEngine: "jimeng", VideoEngine: "seedance", ImageEngines: map[string]ProviderTarget{ - "jimeng": {Provider: "volcengine-visual", Model: "jimeng-test"}, - "evolink": {Provider: "evolink", Model: "gpt-image-test"}, - "bailian": {Provider: "bailian", Model: "bailian-image-test"}, + "jimeng": {Provider: "volcengine-visual", Model: "jimeng-test"}, + "evolink": {Provider: "evolink", Model: "gpt-image-test"}, + "bailian": {Provider: "bailian", Model: "bailian-image-test"}, + "seedream": {Provider: "seedream", Model: providers.Seedream50ProModel, Settings: map[string]any{"size": "1.5K", "outputFormat": "png", "optimizeMode": "standard", "imageCount": float64(1)}}, }, 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"}, }, + VideoModels: map[string]ProviderTarget{ + seedance20Model: {Provider: "seedance", Model: seedance20Model, Settings: map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}}, + seedance25Model: {Provider: "seedance", Model: seedance25Model, Settings: map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}}, + }, NewID: func() string { return "job-1" }, } } diff --git a/backend/internal/orchestration/creation.go b/backend/internal/orchestration/creation.go index 78c2a68..a26d001 100644 --- a/backend/internal/orchestration/creation.go +++ b/backend/internal/orchestration/creation.go @@ -245,7 +245,7 @@ func retryEngine(provider, capability string) string { switch provider { case "volcengine-visual": return "jimeng" - case "evolink", "bailian": + case "evolink", "bailian", "seedream": return provider default: return "" diff --git a/backend/internal/orchestration/settlement.go b/backend/internal/orchestration/settlement.go index 6f5ed2d..56a0840 100644 --- a/backend/internal/orchestration/settlement.go +++ b/backend/internal/orchestration/settlement.go @@ -77,7 +77,7 @@ func (p *SettlementProcessor) Advance(ctx context.Context, job jobs.Job) (jobs.J resolution := seedanceResolution(charge.raw) inputVideo := seedanceRequestHasInputVideo(advanced.RequestPayload) actualAmount, err := billing.CalculateSeedanceActualAmountFen(billing.SeedanceActualAmountInput{ - Resolution: resolution, InputVideo: inputVideo, CompletionTokens: completionTokens, + Model: advanced.ReqKey, Resolution: resolution, InputVideo: inputVideo, CompletionTokens: completionTokens, MarkupMultiplier: numberOrZero(charge.raw["markupMultiplier"]), }) if err != nil { @@ -104,7 +104,7 @@ func (p *SettlementProcessor) Advance(ctx context.Context, job jobs.Job) (jobs.J Metadata: map[string]any{ "operation": "seedance_actual_settlement", "reservedAmountFen": reservedAmount, "chargedAmountFen": charge.AmountFen, "actualAmountFen": actualAmount, - "completionTokens": completionTokens, "inputVideo": inputVideo, "resolution": resolution, + "completionTokens": completionTokens, "inputVideo": inputVideo, "resolution": resolution, "model": advanced.ReqKey, }, }) if postErr != nil { @@ -125,8 +125,8 @@ func (p *SettlementProcessor) Advance(ctx context.Context, job jobs.Job) (jobs.J delete(charge.raw, "settlementLedgerEntryId") } charge.raw["providerUsage"] = map[string]any{ - "completionTokens": completionTokens, "resolution": resolution, "inputVideo": inputVideo, - "tokenPriceFenPerMillion": billing.SeedanceTokenPriceFenPerMillion(resolution, inputVideo), + "completionTokens": completionTokens, "resolution": resolution, "inputVideo": inputVideo, "model": advanced.ReqKey, + "tokenPriceFenPerMillion": billing.SeedanceTokenPriceFenPerMillion(advanced.ReqKey, resolution, inputVideo), } return p.write(ctx, advanced, charge.raw) } diff --git a/backend/internal/orchestration/settlement_test.go b/backend/internal/orchestration/settlement_test.go index 4b4992f..e4bb1af 100644 --- a/backend/internal/orchestration/settlement_test.go +++ b/backend/internal/orchestration/settlement_test.go @@ -52,6 +52,25 @@ func TestSettlementProcessorMarksMissingProviderUsageEstimated(t *testing.T) { } } +func TestSettlementProcessorUsesSeedance25RateFromJobReqKey(t *testing.T) { + state := &settlementStateStub{} + ledger := &settlementLedgerStub{} + advanced := seedanceSettlementJob(`{"usage":{"completionTokens":20000}}`, `{"status":"charged","amountFen":100,"markupMultiplier":1.2,"parameters":{"resolution":"720p"}}`) + advanced.ReqKey = billing.Seedance25Model + processor := NewSettlementProcessor(settlementProcessorStub{job: advanced}, ledger, state, time.Now) + + got, err := processor.Advance(context.Background(), jobs.Job{ID: "job-1", Provider: "seedance", Status: jobs.StatusRunning}) + if err != nil || ledger.calls != 1 || ledger.request.DeltaFen != 68 { + 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(168) || usage["model"] != billing.Seedance25Model || usage["tokenPriceFenPerMillion"] != float64(7000) { + t.Fatalf("billing snapshot = %#v", snapshot) + } +} + func TestSettlementProcessorQuotaExemptUpdatesSnapshotWithoutWalletAndIsIdempotent(t *testing.T) { state := &settlementStateStub{} ledger := &settlementLedgerStub{} diff --git a/backend/internal/postgres/database.go b/backend/internal/postgres/database.go index 6ccb13f..18a8f43 100644 --- a/backend/internal/postgres/database.go +++ b/backend/internal/postgres/database.go @@ -47,6 +47,16 @@ SELECT AND column_name = required_column.column_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 has_function_privilege( current_user, 'public.claim_generation_jobs(text,integer,integer)', diff --git a/backend/internal/postgres/database_test.go b/backend/internal/postgres/database_test.go index 912cd22..f056f17 100644 --- a/backend/internal/postgres/database_test.go +++ b/backend/internal/postgres/database_test.go @@ -56,6 +56,18 @@ func TestReadinessSQLRequiresPublicGenerationJobLifecycleColumns(t *testing.T) { } } +func TestReadinessSQLRequiresSeedreamBillingProviderMigration(t *testing.T) { + for _, fragment := range []string{ + "billing_price_rules_provider_check", + "pg_get_constraintdef", + "position('seedream'", + } { + if !strings.Contains(ReadinessSQL, fragment) { + t.Errorf("ReadinessSQL missing Seedream billing migration check %q", fragment) + } + } +} + func TestReadinessLocalSucceedsWithoutQuery(t *testing.T) { db := NewDatabase(Config{Backend: BackendLocal}, &fakeQuerier{err: errors.New("must not query")}) if err := db.Readiness(context.Background()); err != nil { diff --git a/backend/internal/providers/adapters.go b/backend/internal/providers/adapters.go index 6a02417..37fa28f 100644 --- a/backend/internal/providers/adapters.go +++ b/backend/internal/providers/adapters.go @@ -147,7 +147,11 @@ func NewSeedance(c Config, client HTTPClient) *Seedance { } content = append(content, map[string]any{"type": materialType, urlKey: map[string]any{"url": material.URL}, "role": role}) } - p := map[string]any{"model": requestModel(r, c.Model), "content": content, "generate_audio": true, "watermark": false} + model := requestModel(r, c.Model) + p := map[string]any{"model": model, "content": content, "generate_audio": true, "watermark": false} + if model == "doubao-seedance-2-5-260628" && len(materials) > 0 { + p["omni_reference_task_type"] = "auto" + } for k, v := range r.Settings { p[k] = v } diff --git a/backend/internal/providers/providers_test.go b/backend/internal/providers/providers_test.go index 8fe8bde..4c3b36a 100644 --- a/backend/internal/providers/providers_test.go +++ b/backend/internal/providers/providers_test.go @@ -7,6 +7,7 @@ import ( "io" "log" "net/http" + "reflect" "strings" "testing" "time" @@ -601,6 +602,26 @@ func TestSeedance20PayloadMatchesOfficialMultimodalContract(t *testing.T) { } } +func TestSeedance25PayloadSelectsAutomaticOmniReferenceTask(t *testing.T) { + 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["model"] != "doubao-seedance-2-5-260628" || body["omni_reference_task_type"] != "auto" { + t.Fatalf("body=%#v", body) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"task-25","status":"queued"}`)), Header: http.Header{}}, nil + }) + request := Request{ + Capability: "video.generate", Model: "doubao-seedance-2-5-260628", Prompt: "combine", + Materials: []Material{{URL: "https://cdn.test/image.png", Type: MaterialImage, Label: "图片1"}}, + } + if _, err := NewSeedance(Config{BaseURL: "https://s.test/api/v3", APIKey: "secret", Model: "doubao-seedance-2-0-260128"}, client).Submit(context.Background(), request); err != nil { + t.Fatal(err) + } +} + func TestEvoLinkPayloadIncludesQualityAndSize(t *testing.T) { client := roundTripFunc(func(request *http.Request) (*http.Response, error) { var body map[string]any @@ -620,6 +641,31 @@ func TestEvoLinkPayloadIncludesQualityAndSize(t *testing.T) { } } +func TestSeedreamPayloadAndSynchronousResponse(t *testing.T) { + client := roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/api/v3/images/generations" || request.Header.Get("Authorization") != "Bearer secret" { + t.Fatalf("request=%s auth=%q", request.URL.Path, request.Header.Get("Authorization")) + } + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + images, ok := body["image"].([]any) + optimize := body["optimize_prompt_options"].(map[string]any) + if body["model"] != Seedream50ProModel || body["prompt"] != "draw" || body["size"] != "2K" || body["output_format"] != "jpeg" || body["response_format"] != "url" || body["watermark"] != false || optimize["mode"] != "fast" || !ok || len(images) != 2 { + t.Fatalf("body=%#v", body) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"created":1,"data":[{"url":"https://cdn.test/a.jpeg"}]}`)), Header: http.Header{}}, nil + }) + result, err := NewSeedream(Config{BaseURL: "https://ark.test/api/v3", APIKey: "secret", Model: Seedream50ProModel}, client).Submit(context.Background(), Request{ + Capability: "image.generate", Prompt: "draw", InputURLs: []string{"https://in.test/a.png", "https://in.test/b.png"}, + Settings: map[string]any{"size": "2K", "outputFormat": "jpeg", "optimizeMode": "fast"}, + }) + if err != nil || result.Status != StatusSucceeded || !reflect.DeepEqual(result.OutputURLs, []string{"https://cdn.test/a.jpeg"}) { + t.Fatalf("result=%#v err=%v", result, err) + } +} + func TestMockIsDeterministic(t *testing.T) { m := NewMock("fixture") a, _ := m.Submit(context.Background(), Request{Capability: "video.generate", Prompt: "hello"}) diff --git a/backend/internal/providers/seedream.go b/backend/internal/providers/seedream.go new file mode 100644 index 0000000..47ca155 --- /dev/null +++ b/backend/internal/providers/seedream.go @@ -0,0 +1,82 @@ +package providers + +import ( + "context" + "errors" + "net/http" + "strings" +) + +const Seedream50ProModel = "doubao-seedream-5-0-pro-260628" + +// Seedream implements the synchronous Ark image generation API. Unlike the +// Visual/Jimeng adapter, a successful submission already contains the output +// URLs and therefore never needs provider-side polling. +type Seedream struct{ *httpAdapter } + +func NewSeedream(c Config, client HTTPClient) *Seedream { + if client == nil { + client = http.DefaultClient + } + a := &httpAdapter{ + name: "seedream", + config: c, + client: client, + submitPath: func(Request) string { return "/images/generations" }, + payload: func(r Request) any { + payload := map[string]any{ + "model": requestModel(r, c.Model), + "prompt": r.Prompt, + "size": seedreamSetting(r.Settings, "size", "1.5K"), + "output_format": seedreamSetting(r.Settings, "outputFormat", "png"), + "response_format": "url", + "watermark": false, + "optimize_prompt_options": map[string]any{ + "mode": seedreamSetting(r.Settings, "optimizeMode", "standard"), + }, + } + switch len(r.InputURLs) { + case 0: + case 1: + payload["image"] = r.InputURLs[0] + default: + payload["image"] = r.InputURLs + } + return payload + }, + decode: decodeSeedream, + } + return &Seedream{a} +} + +func (a *Seedream) Submit(ctx context.Context, request Request) (Result, error) { + return a.submit(ctx, request) +} + +func (a *Seedream) Query(context.Context, string) (Result, error) { + return Result{}, errors.New("seedream generation is synchronous") +} + +func decodeSeedream(raw []byte) Result { + record := record(raw) + urls := []string{} + collectURLs(record["data"], &urls) + result := Result{OutputURLs: urls} + if len(urls) > 0 { + result.Status = StatusSucceeded + } else { + result.Status = StatusFailed + result.ErrorMessage = stringValue(object(record["error"])["message"], record["message"]) + } + return result +} + +func seedreamSetting(settings map[string]any, key, fallback string) string { + value := strings.TrimSpace(stringValue(settings[key])) + if value == "" { + return fallback + } + return value +} + +var _ Adapter = (*Seedream)(nil) diff --git a/backend/internal/settings/service.go b/backend/internal/settings/service.go index 6daa75b..94f8999 100644 --- a/backend/internal/settings/service.go +++ b/backend/internal/settings/service.go @@ -66,6 +66,7 @@ type Services struct { Visual bool `json:"visual"` Evolink bool `json:"evolink"` Seedance bool `json:"seedance"` + Seedream bool `json:"seedream"` Bailian bool `json:"bailian"` Auth bool `json:"auth"` Organization bool `json:"organization"` @@ -316,7 +317,7 @@ func (s *Service) get() (Payload, error) { } image := normalizeImage(current(fieldIndex["IMAGE_GENERATE_ENGINE"])) video := normalizeVideo(current(fieldIndex["VIDEO_GENERATE_ENGINE"])) - imageModel := map[string]string{"jimeng": lookup(s.database, s.environment, file, "JIMENG_IMAGE_GENERATE_46_REQ_KEY", "jimeng_seedream46_cvtob"), "evolink": lookup(s.database, s.environment, file, "EVOLINK_IMAGE_MODEL", "gpt-image-2"), "bailian": lookup(s.database, s.environment, file, "BAILIAN_IMAGE_MODEL", "wan2.7-image-pro")}[image] + imageModel := map[string]string{"jimeng": lookup(s.database, s.environment, file, "JIMENG_IMAGE_GENERATE_46_REQ_KEY", "jimeng_seedream46_cvtob"), "evolink": lookup(s.database, s.environment, file, "EVOLINK_IMAGE_MODEL", "gpt-image-2"), "bailian": lookup(s.database, s.environment, file, "BAILIAN_IMAGE_MODEL", "wan2.7-image-pro"), "seedream": "doubao-seedream-5-0-pro-260628"}[image] 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") @@ -326,8 +327,12 @@ func (s *Service) get() (Payload, error) { 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), 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", "") != ""} - return Payload{Services: services, Capabilities: []Capability{{ID: "image.generate", Label: "图片生成 4.6", ReqKey: imageModel, Engine: image, EngineLabel: label(image), Enabled: true}, {ID: "video.generate", Label: "视频生成", ReqKey: videoModel, Engine: video, EngineLabel: label(video), Enabled: true}}, EngineAssignments: assignments, Groups: groups}, nil + 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", "") != ""} + imageLabel := "图片生成 4.6" + if image == "seedream" { + imageLabel = "Seedream 5.0 Pro 图片生成" + } + return Payload{Services: services, Capabilities: []Capability{{ID: "image.generate", Label: imageLabel, ReqKey: imageModel, Engine: image, EngineLabel: label(image), Enabled: true}, {ID: "video.generate", Label: "视频生成", ReqKey: videoModel, Engine: video, EngineLabel: label(video), Enabled: true}}, EngineAssignments: assignments, Groups: groups}, nil } func (s *Service) refreshDatabaseSettings(ctx context.Context) error { @@ -535,7 +540,7 @@ func project(field Field, value string, database, environment, file map[string]s } func normalizeImage(value string) string { value = strings.ToLower(strings.TrimSpace(value)) - if value == "evolink" || value == "bailian" { + if value == "evolink" || value == "bailian" || value == "seedream" { return value } return "jimeng" @@ -554,6 +559,8 @@ func label(engine string) string { return "阿里云百炼" case "seedance": return "Seedance" + case "seedream": + return "Seedream 5.0 Pro" default: return "即梦" } @@ -568,7 +575,7 @@ func connected(engine string, database, environment, file map[string]string) boo switch engine { case "evolink": return strings.TrimSpace(lookup(database, environment, file, "EVOLINK_API_KEY", "")) != "" - case "seedance": + case "seedance", "seedream": 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", "")) != "" @@ -583,7 +590,7 @@ func definitions() []Group { {ID: "billing", Title: "企业计费", Description: "企业计费和对公账户信息。", Fields: []Field{{Key: "ZHINIAN_BILLING_REQUIRED", Label: "真实任务计费", Type: "select", DefaultValue: "1", Options: []Option{{Label: "启用", Value: "1"}, {Label: "停用(免计费)", Value: "0"}}}, {Key: "ZHINIAN_BILLING_ACCOUNT_NAME", Label: "对公账户名称"}, {Key: "ZHINIAN_BILLING_ACCOUNT_BANK", Label: "开户行"}, {Key: "ZHINIAN_BILLING_ACCOUNT_NUMBER", Label: "银行账号"}, {Key: "ZHINIAN_BILLING_CONTACT", Label: "充值对接信息"}}}, {ID: "visual", Title: "即梦图片 API", Description: "火山 AK/SK。", Fields: []Field{{Key: "VOLCENGINE_ACCESS_KEY_ID", Label: "Access Key ID", Secret: true, Type: "password"}, {Key: "VOLCENGINE_SECRET_ACCESS_KEY", Label: "Secret Access Key", Secret: true, Type: "password"}}}, {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: "Seedance 视频 API", Description: "火山方舟 API Key。", Fields: []Field{{Key: "SEEDANCE_API_KEY", Label: "方舟 API Key", Secret: true, Type: "password"}}}, + {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: "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(无需公共读)"}}}, } @@ -596,7 +603,7 @@ var fieldIndex = func() map[string]Field { result[field.Key] = field } } - result["IMAGE_GENERATE_ENGINE"] = Field{Key: "IMAGE_GENERATE_ENGINE", Label: "图片生成", Type: "select", DefaultValue: "jimeng", Options: []Option{{Label: "即梦 / 火山视觉", Value: "jimeng"}, {Label: "EvoLink GPT Image 2", Value: "evolink"}, {Label: "阿里云百炼 Wan 2.7", Value: "bailian"}}} + 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"}}} return result }() diff --git a/backend/internal/templates/service.go b/backend/internal/templates/service.go index 97a98f6..5b74dfc 100644 --- a/backend/internal/templates/service.go +++ b/backend/internal/templates/service.go @@ -19,12 +19,15 @@ var ( ) type Settings struct { - Engine string `json:"engine,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - ForceSingle *bool `json:"forceSingle,omitempty"` - Scale float64 `json:"scale,omitempty"` - Quality string `json:"quality,omitempty"` + Engine string `json:"engine,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + ForceSingle *bool `json:"forceSingle,omitempty"` + Scale float64 `json:"scale,omitempty"` + Quality string `json:"quality,omitempty"` + Size string `json:"size,omitempty"` + OutputFormat string `json:"outputFormat,omitempty"` + OptimizeMode string `json:"optimizeMode,omitempty"` } type Template struct { @@ -225,7 +228,7 @@ func previewURL(value string) (string, error) { return value, nil } func normalizeSettings(value Settings) Settings { - if value.Engine != "jimeng" && value.Engine != "evolink" && value.Engine != "bailian" { + if value.Engine != "jimeng" && value.Engine != "evolink" && value.Engine != "bailian" && value.Engine != "seedream" { value.Engine = "" } if value.Width < 1 || value.Height < 1 { @@ -241,6 +244,18 @@ func normalizeSettings(value Settings) Settings { if value.Quality != "low" && value.Quality != "medium" && value.Quality != "high" { value.Quality = "" } + value.Size = strings.ToUpper(strings.TrimSpace(value.Size)) + if value.Size != "1K" && value.Size != "1.5K" && value.Size != "2K" { + value.Size = "" + } + value.OutputFormat = strings.ToLower(strings.TrimSpace(value.OutputFormat)) + if value.OutputFormat != "png" && value.OutputFormat != "jpeg" { + value.OutputFormat = "" + } + value.OptimizeMode = strings.ToLower(strings.TrimSpace(value.OptimizeMode)) + if value.OptimizeMode != "standard" && value.OptimizeMode != "fast" { + value.OptimizeMode = "" + } return value } func templateID() string { diff --git a/components/asset-manager.tsx b/components/asset-manager.tsx index 887e888..df9a71b 100644 --- a/components/asset-manager.tsx +++ b/components/asset-manager.tsx @@ -488,6 +488,7 @@ function providerLabel(provider: GenerationJob["provider"]) { if (provider === "volcengine-visual") return "火山视觉"; if (provider === "evolink") return "EvoLink"; if (provider === "seedance") return "Seedance"; + if (provider === "seedream") return "Seedream 5.0 Pro"; if (provider === "bailian") return "阿里云百炼"; return "系统生成"; } diff --git a/components/billing-manager.tsx b/components/billing-manager.tsx index fcc6ec5..2cf62d5 100644 --- a/components/billing-manager.tsx +++ b/components/billing-manager.tsx @@ -761,6 +761,7 @@ function providerName(provider: string) { if (provider === "volcengine-visual") return "即梦"; if (provider === "evolink") return "EvoLink"; if (provider === "seedance") return "Seedance"; + if (provider === "seedream") return "Seedream 5.0 Pro"; if (provider === "bailian") return "百炼"; return provider; } diff --git a/components/create-studio.tsx b/components/create-studio.tsx index 51639ff..5f1bf59 100644 --- a/components/create-studio.tsx +++ b/components/create-studio.tsx @@ -5,7 +5,16 @@ import { Check, CircleDollarSign, Download, Film, ImageIcon, ImagePlus, Info, Lo import clsx from "clsx"; import { clampPage, pageItems, Pagination } from "@/components/pagination"; import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; -import { VIDEO_DURATION_DEFAULT, VIDEO_DURATION_OPTIONS, VIDEO_RATIOS, VIDEO_RESOLUTIONS, clampVideoDuration } from "@/lib/video-settings"; +import { + SEEDANCE_20_MODEL, + SEEDANCE_MODELS, + VIDEO_DURATION_DEFAULT, + VIDEO_RATIOS, + VIDEO_RESOLUTIONS, + clampVideoDuration, + normalizeSeedanceModel, + seedanceDurationOptions +} from "@/lib/video-settings"; import type { Asset, GenerationJob } from "@/lib/types"; import type { PromptMaterial } from "@/lib/prompt/assembler"; import { @@ -22,7 +31,7 @@ import type { BillingQuote } from "@/lib/types"; type GenerateMode = "image" | "video"; type MaterialKind = PromptMaterial["type"]; -type ImageGenerateEngine = "jimeng" | "evolink" | "bailian"; +type ImageGenerateEngine = "jimeng" | "evolink" | "bailian" | "seedream"; type VideoGenerateEngine = "seedance" | "bailian"; type MentionState = { @@ -49,6 +58,7 @@ type MaterialDraftOption = { type HealthCapability = { id: string; engine?: string; + reqKey?: string; }; type ImageTemplate = { @@ -63,6 +73,9 @@ type ImageTemplate = { forceSingle?: boolean; scale?: number; quality?: string; + size?: string; + outputFormat?: string; + optimizeMode?: string; }; sortOrder: number; updatedAt?: string; @@ -75,6 +88,9 @@ type TemplateForm = { engine: ImageGenerateEngine; jimengInfluence: string; evolinkQuality: string; + seedreamSize: string; + seedreamOutputFormat: string; + seedreamOptimizeMode: string; size: string; sortOrder: string; }; @@ -92,6 +108,16 @@ const evolinkQualityOptions = [ { id: "high", label: "精细", quality: "high" } ]; +const seedreamSizeOptions = ["1K", "1.5K", "2K"] as const; +const seedreamOutputFormatOptions = [ + { id: "png", label: "PNG" }, + { id: "jpeg", label: "JPEG" } +] as const; +const seedreamOptimizeModeOptions = [ + { id: "standard", label: "标准优化" }, + { id: "fast", label: "快速优化" } +] as const; + const imageSizePresets = [ { label: "1:1", width: 2048, height: 2048 }, { label: "4:3", width: 2304, height: 1728 }, @@ -107,6 +133,9 @@ type TemplateConsoleSnapshot = { imageEngine: ImageGenerateEngine; jimengInfluence: string; evolinkQuality: string; + seedreamSize: string; + seedreamOutputFormat: string; + seedreamOptimizeMode: string; }; const materialUploadAccept = "image/*,video/*,audio/*"; @@ -119,6 +148,9 @@ const defaultTemplateForm: TemplateForm = { engine: "jimeng", jimengInfluence: jimengInfluenceOptions[1].id, evolinkQuality: evolinkQualityOptions[1].id, + seedreamSize: "1.5K", + seedreamOutputFormat: "png", + seedreamOptimizeMode: "standard", size: imageSizePresets[0].label, sortOrder: "0" }; @@ -158,7 +190,11 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate const [imageEngine, setImageEngine] = useState("jimeng"); const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id); const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id); + const [seedreamSize, setSeedreamSize] = useState("1.5K"); + const [seedreamOutputFormat, setSeedreamOutputFormat] = useState("png"); + const [seedreamOptimizeMode, setSeedreamOptimizeMode] = useState("standard"); const [videoEngine, setVideoEngine] = useState("bailian"); + const [videoModel, setVideoModel] = useState(SEEDANCE_20_MODEL); const [videoRatio, setVideoRatio] = useState("9:16"); const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT); const [videoResolution, setVideoResolution] = useState("720p"); @@ -182,6 +218,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate const selectedEvolinkQuality = evolinkQualityOptions.find((option) => option.id === evolinkQuality) || evolinkQualityOptions[1]; const selectedTemplateJimengInfluence = jimengInfluenceOptions.find((option) => option.id === templateForm.jimengInfluence) || jimengInfluenceOptions[1]; const selectedTemplateEvolinkQuality = evolinkQualityOptions.find((option) => option.id === templateForm.evolinkQuality) || evolinkQualityOptions[1]; + const selectedTemplateSeedreamOptimizeMode = seedreamOptimizeModeOptions.find((option) => option.id === templateForm.seedreamOptimizeMode) || seedreamOptimizeModeOptions[0]; const templateFormReady = Boolean(templateForm.name.trim() && templateForm.prompt.trim() && templateForm.previewImageUrl.trim()); const templateFormPlaceholders = useMemo(() => extractMaterialPlaceholders(templateForm.prompt), [templateForm.prompt]); const materialPlaceholders = useMemo(() => extractMaterialPlaceholders(prompt), [prompt]); @@ -255,8 +292,19 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate void fetch("/api/health") .then((response) => response.ok ? response.json() : null) .then((payload: { capabilities?: HealthCapability[] } | null) => { - const engine = payload?.capabilities?.find((capability) => capability.id === "image.generate")?.engine; - if (active && (engine === "evolink" || engine === "jimeng" || engine === "bailian")) setImageEngine(engine); + const imageCapability = payload?.capabilities?.find((capability) => capability.id === "image.generate"); + const videoCapability = payload?.capabilities?.find((capability) => capability.id === "video.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")) { + 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 })); + } + } }) .catch(() => undefined); return () => { @@ -344,7 +392,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate active = false; window.clearTimeout(timer); }; - }, [generateMode, imageEngine, imageSize.height, imageSize.width, selectedEvolinkQuality.quality, selectedJimengInfluence.scale, videoDuration, videoEngine, videoRatio, videoResolution, materials, missingMaterialPlaceholders.length, prompt]); + }, [generateMode, imageEngine, imageSize.height, imageSize.width, seedreamOptimizeMode, seedreamOutputFormat, seedreamSize, selectedEvolinkQuality.quality, selectedJimengInfluence.scale, videoDuration, videoEngine, videoModel, videoRatio, videoResolution, materials, missingMaterialPlaceholders.length, prompt]); async function loadImageTemplates(isActive: () => boolean = () => true) { setTemplatesLoading(true); @@ -546,7 +594,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate imageSize, imageEngine, jimengInfluence, - evolinkQuality + evolinkQuality, + seedreamSize, + seedreamOutputFormat, + seedreamOptimizeMode }); } applyTemplateToConsole(template, `已应用模板:${template.name}`); @@ -566,12 +617,18 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setImageEngine(snapshot.imageEngine); setJimengInfluence(snapshot.jimengInfluence); setEvolinkQuality(snapshot.evolinkQuality); + setSeedreamSize(snapshot.seedreamSize); + setSeedreamOutputFormat(snapshot.seedreamOutputFormat); + setSeedreamOptimizeMode(snapshot.seedreamOptimizeMode); } else { setPromptByMode((items) => ({ ...items, [generateMode]: "" })); setImageSize(imageSizePresets[0]); setImageEngine("jimeng"); setJimengInfluence(jimengInfluenceOptions[1].id); setEvolinkQuality(evolinkQualityOptions[1].id); + setSeedreamSize("1.5K"); + setSeedreamOutputFormat("png"); + setSeedreamOptimizeMode("standard"); } setNotice("已取消模板选择"); } @@ -589,6 +646,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate if (templateInfluence) setJimengInfluence(templateInfluence.id); const templateQuality = evolinkQualityOptions.find((option) => option.quality === template.settings.quality); if (templateQuality) setEvolinkQuality(templateQuality.id); + if (seedreamSizeOptions.includes(template.settings.size as (typeof seedreamSizeOptions)[number])) setSeedreamSize(template.settings.size!); + if (template.settings.outputFormat === "png" || template.settings.outputFormat === "jpeg") setSeedreamOutputFormat(template.settings.outputFormat); + if (template.settings.optimizeMode === "standard" || template.settings.optimizeMode === "fast") setSeedreamOptimizeMode(template.settings.optimizeMode); } setMentionState(null); setPromptScrollTop(0); @@ -796,6 +856,20 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate function buildGenerationBody(): Record { if (generateMode === "image") { + if (imageEngine === "seedream") { + return { + capability: "image.generate", + engine: imageEngine, + prompt, + materials: materials.filter((material) => material.type === "image"), + settings: { + size: seedreamSize, + outputFormat: seedreamOutputFormat, + optimizeMode: seedreamOptimizeMode, + imageCount: 1 + } + }; + } return { capability: "image.generate", engine: imageEngine, @@ -813,6 +887,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate kind: "video", capability: "video.generate", engine: videoEngine, + ...(videoEngine === "seedance" ? { model: videoModel } : {}), prompt, materials, settings: { @@ -833,6 +908,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setNotice(null); try { const endpoint = generateMode === "image" ? "/api/generations/image" : "/api/generations/video"; + if (generateMode === "image" && imageEngine === "seedream" && materials.filter((material) => material.type === "image").length > 10) { + throw new Error("Seedream 5.0 Pro 最多支持 10 张参考图。"); + } if (generateMode === "video" && videoEngine === "bailian") { const imageCount = materials.filter((material) => material.type === "image").length; if (imageCount < 1 || imageCount > 2 || materials.some((material) => material.type !== "image")) { @@ -1113,45 +1191,82 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
- - + + {imageEngine === "seedream" ? ( + + ) : ( + + )}
- + {imageEngine === "evolink" ? ( ) : imageEngine === "bailian" ? ( + ) : imageEngine === "seedream" ? ( + ) : ( )}
+ {imageEngine === "seedream" ? ( +
+ + +
+ ) : null} ) : ( <>
- { + const nextEngine = event.target.value === "bailian" ? "bailian" : "seedance"; + setVideoEngine(nextEngine); + setVideoDuration((duration) => nextEngine === "bailian" + ? Math.min(15, Math.max(2, duration)) + : clampVideoDuration(duration, VIDEO_DURATION_DEFAULT, { allowAuto: false, model: videoModel })); + }}>
{videoEngine === "seedance" ? ( +
+ + +
+ ) : null} + {videoEngine === "seedance" ? (
setVideoDuration(clampVideoDuration(event.target.value, videoDuration, { allowAuto: false }))} + onChange={(event) => setVideoDuration(clampVideoDuration(event.target.value, videoDuration, { allowAuto: false, model: videoModel }))} > - {(videoEngine === "bailian" ? Array.from({ length: 14 }, (_, index) => index + 2) : VIDEO_DURATION_OPTIONS).map((seconds) => )} + {(videoEngine === "bailian" ? Array.from({ length: 14 }, (_, index) => index + 2) : seedanceDurationOptions(videoModel)).map((seconds) => )}
@@ -1230,8 +1345,8 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
{imageEngineLabel(templateForm.engine)} - {templateForm.engine === "evolink" ? selectedTemplateEvolinkQuality.label : templateForm.engine === "bailian" ? "智能推理" : selectedTemplateJimengInfluence.label} - {templateForm.size} + {templateForm.engine === "evolink" ? selectedTemplateEvolinkQuality.label : templateForm.engine === "bailian" ? "智能推理" : templateForm.engine === "seedream" ? selectedTemplateSeedreamOptimizeMode.label : selectedTemplateJimengInfluence.label} + {templateForm.engine === "seedream" ? templateForm.seedreamSize : templateForm.size}
@@ -1258,6 +1373,14 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate > Image2 +
+ {templateForm.engine === "seedream" ? ( + + ) : null}