From f642b5e71ff8dc83ce35949c6a26894568ade1bf Mon Sep 17 00:00:00 2001 From: inman Date: Tue, 14 Jul 2026 12:58:50 +0800 Subject: [PATCH] feat: add Bailian image generation support --- README.md | 1 + README.zh-CN.md | 6 + app/api/health/route.ts | 7 +- app/api/v1/capabilities/route.ts | 19 ++- app/api/v1/openapi.json/route.ts | 2 +- components/asset-manager.tsx | 1 + components/create-studio.tsx | 39 ++++-- components/settings-panel.tsx | 2 + lib/bailian/client.ts | 158 +++++++++++++++++++++++++ lib/evolink/image-client.ts | 6 +- lib/server/app-settings.ts | 67 ++++++++--- lib/server/data-store.ts | 2 +- lib/server/generation-service.ts | 56 +++++++-- lib/server/image-template-input.ts | 2 +- lib/server/public-api-jobs.ts | 4 + lib/server/video-generation-service.ts | 84 ++++++++----- lib/types.ts | 4 +- tests/bailian-client.test.ts | 78 ++++++++++++ 18 files changed, 464 insertions(+), 74 deletions(-) create mode 100644 lib/bailian/client.ts create mode 100644 tests/bailian-client.test.ts diff --git a/README.md b/README.md index 62fc8c6..a7260db 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ https://你的域名/api/auth/callback - `jimeng`:默认引擎,走火山 Visual 即梦能力。 - `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. 智能超清仍走即梦超清能力。 diff --git a/README.zh-CN.md b/README.zh-CN.md index 656624e..f09b2b6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -201,6 +201,7 @@ https://你的域名/api/auth/callback - `jimeng`:火山 Visual 即梦能力 - `evolink`:EvoLink GPT Image 2 中转接口 +- `bailian`:阿里云百炼万相 2.7,支持文生图、最多 9 张参考图生图,以及 1–2 张首尾帧图生视频 ### 图片编辑 @@ -296,6 +297,11 @@ cp .env.example .env.local | `ZHINIAN_WORKER_*` | Worker 间隔、批量、锁超时、重试配置 | | `IMAGE_GENERATE_ENGINE` | 图片生成引擎:`jimeng` 或 `evolink` | | `IMAGE_INPAINT_ENGINE` | 局部重绘引擎:`jimeng` 或 `evolink` | +| `BAILIAN_API_KEY` | 阿里云百炼 API Key | +| `BAILIAN_BASE_URL` | 百炼业务空间兼容地址;系统自动派生原生异步接口 | +| `BAILIAN_IMAGE_MODEL` | 图片模型,默认 `wan2.7-image-pro` | +| `BAILIAN_VIDEO_MODEL` | 视频模型,默认 `wan2.7-i2v-2026-04-25` | +| `VIDEO_GENERATE_ENGINE` | 视频生成引擎:`seedance` 或 `bailian` | | `VOLCENGINE_ACCESS_KEY_ID` | 火山引擎 Access Key | | `VOLCENGINE_SECRET_ACCESS_KEY` | 火山引擎 Secret Key | | `EVOLINK_API_KEY` | EvoLink API Key | diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 95b139f..3ce1833 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -4,12 +4,14 @@ import { getEffectiveImageEngine, getEvolinkImageSettings, shouldMockEvolinkApi import { getVisibleImageCapabilities } from "@/lib/jimeng/capabilities"; import { shouldMockVisualApi } from "@/lib/volcengine/visual-client"; import { getSeedanceConfig, shouldMockSeedance } from "@/lib/seedance/client"; +import { getBailianConfig, shouldMockBailian } from "@/lib/bailian/client"; export const runtime = "nodejs"; export async function GET() { const evolink = getEvolinkImageSettings(); const auth = getAuthRuntimeConfig(); + const bailian = getBailianConfig(); return jsonOk({ ok: true, appId: "zhinian-web-studio", @@ -17,6 +19,7 @@ export async function GET() { visualApiMode: shouldMockVisualApi() ? "mock" : "volcengine", evolinkMode: shouldMockEvolinkApi() ? "mock" : "evolink", seedanceMode: shouldMockSeedance() ? "mock" : "seedance", + bailianMode: shouldMockBailian() ? "mock" : bailian.apiKey ? "bailian" : "missing", authMode: authConfigSummary(auth), capabilities: [ ...getVisibleImageCapabilities().map((capability) => { @@ -25,8 +28,8 @@ export async function GET() { id: capability.id, label: capability.label, engine, - engineLabel: engine === "evolink" ? "EvoLink" : "即梦", - reqKey: engine === "evolink" ? evolink.model : capability.reqKey + engineLabel: engine === "evolink" ? "EvoLink" : engine === "bailian" ? "阿里云百炼" : "即梦", + reqKey: engine === "bailian" ? bailian.imageModel : engine === "evolink" ? evolink.model : capability.reqKey }; }), { diff --git a/app/api/v1/capabilities/route.ts b/app/api/v1/capabilities/route.ts index 42b0967..8eafe17 100644 --- a/app/api/v1/capabilities/route.ts +++ b/app/api/v1/capabilities/route.ts @@ -4,6 +4,7 @@ import { jsonOk } from "@/lib/server/api"; import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; import { publicApiError } from "@/lib/server/public-api-response"; import { getSeedanceConfig } from "@/lib/seedance/client"; +import { getBailianConfig } from "@/lib/bailian/client"; export const runtime = "nodejs"; @@ -11,6 +12,7 @@ export async function GET(request: Request) { try { authenticatePublicApiRequest(request); const evolink = getEvolinkImageSettings(); + const bailian = getBailianConfig(); return jsonOk({ capabilities: [ ...getVisibleImageCapabilities().map((capability) => { @@ -20,8 +22,8 @@ export async function GET(request: Request) { label: capability.label, kind: "image", engine, - provider: engine === "evolink" ? "evolink" : "volcengine-visual", - reqKey: engine === "evolink" ? evolink.model : capability.reqKey + provider: engine === "bailian" ? "bailian" : engine === "evolink" ? "evolink" : "volcengine-visual", + reqKey: engine === "bailian" ? bailian.imageModel : engine === "evolink" ? evolink.model : capability.reqKey }; }), { @@ -36,6 +38,19 @@ export async function GET(request: Request) { ratios: ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"], resolutions: ["480p", "720p", "1080p"] } + }, + { + id: "video.generate.bailian", + label: "百炼图生视频", + kind: "video", + engine: "bailian", + provider: "bailian", + reqKey: bailian.videoModel, + limits: { + inputImages: { min: 1, max: 2 }, + durationSeconds: { min: 2, max: 15 }, + resolutions: ["720P", "1080P"] + } } ] }); diff --git a/app/api/v1/openapi.json/route.ts b/app/api/v1/openapi.json/route.ts index b256928..549b639 100644 --- a/app/api/v1/openapi.json/route.ts +++ b/app/api/v1/openapi.json/route.ts @@ -53,7 +53,7 @@ export async function GET(request: Request) { ownerId: { type: "string", example: "api:partner-a" }, externalClientId: { type: "string" }, capability: { $ref: "#/components/schemas/GenerationCapability" }, - provider: { type: "string", enum: ["volcengine-visual", "evolink", "seedance", "mock"] }, + provider: { type: "string", enum: ["volcengine-visual", "evolink", "seedance", "bailian", "mock"] }, reqKey: { type: "string" }, status: { $ref: "#/components/schemas/GenerationStatus" }, prompt: { type: "string" }, diff --git a/components/asset-manager.tsx b/components/asset-manager.tsx index a546be2..29011af 100644 --- a/components/asset-manager.tsx +++ b/components/asset-manager.tsx @@ -470,6 +470,7 @@ function providerLabel(provider: GenerationJob["provider"]) { if (provider === "volcengine-visual") return "火山视觉"; if (provider === "evolink") return "EvoLink"; if (provider === "seedance") return "Seedance"; + if (provider === "bailian") return "阿里云百炼"; return "Mock"; } diff --git a/components/create-studio.tsx b/components/create-studio.tsx index e12669c..fa64279 100644 --- a/components/create-studio.tsx +++ b/components/create-studio.tsx @@ -21,7 +21,8 @@ import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders" type GenerateMode = "image" | "video"; type StudioMode = GenerateMode | ImageEditMode; type MaterialKind = PromptMaterial["type"]; -type ImageGenerateEngine = "jimeng" | "evolink"; +type ImageGenerateEngine = "jimeng" | "evolink" | "bailian"; +type VideoGenerateEngine = "seedance" | "bailian"; type MentionState = { start: number; @@ -154,6 +155,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo const [imageEngine, setImageEngine] = useState("jimeng"); const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id); const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id); + const [videoEngine, setVideoEngine] = useState("seedance"); const [videoRatio, setVideoRatio] = useState("9:16"); const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT); const [videoResolution, setVideoResolution] = useState("720p"); @@ -247,7 +249,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo .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")) setImageEngine(engine); + if (active && (engine === "evolink" || engine === "jimeng" || engine === "bailian")) setImageEngine(engine); }) .catch(() => undefined); return () => { @@ -743,6 +745,12 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo setNotice(null); try { const endpoint = generateMode === "image" ? "/api/generations/image" : "/api/generations/video"; + 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")) { + throw new Error("百炼图生视频请上传 1 张首帧图,或按顺序上传 2 张首尾帧图。"); + } + } const body = generateMode === "image" ? { capability: "image.generate", @@ -757,12 +765,13 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo force_single: true } : { + engine: videoEngine, prompt, materials, settings: { - ratio: videoRatio, + ...(videoEngine === "seedance" ? { ratio: videoRatio } : {}), duration: videoDuration, - resolution: videoResolution + resolution: videoEngine === "bailian" ? videoResolution.toUpperCase() : videoResolution } }; const response = await fetch(endpoint, { @@ -1056,6 +1065,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
@@ -1069,11 +1079,13 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
- + {imageEngine === "evolink" ? ( + ) : imageEngine === "bailian" ? ( + ) : ( setVideoEngine(event.target.value === "bailian" ? "bailian" : "seedance")}> + + + +
+ {videoEngine === "seedance" ? (
+ ) : null}
@@ -1537,12 +1558,12 @@ function sizeLabelFromSettings(width?: number, height?: number) { } function normalizeImageEngine(value?: string): ImageGenerateEngine | undefined { - if (value === "jimeng" || value === "evolink") return value; + if (value === "jimeng" || value === "evolink" || value === "bailian") return value; return undefined; } function imageEngineLabel(engine: ImageGenerateEngine) { - return engine === "evolink" ? "Image2" : "即梦"; + return engine === "evolink" ? "Image2" : engine === "bailian" ? "百炼" : "即梦"; } function sortTemplates(a: ImageTemplate, b: ImageTemplate) { diff --git a/components/settings-panel.tsx b/components/settings-panel.tsx index f4d2930..24d200f 100644 --- a/components/settings-panel.tsx +++ b/components/settings-panel.tsx @@ -28,6 +28,7 @@ type SettingsPayload = { visual: string; evolink: string; seedance: string; + bailian: string; auth: string; organization: string; data: string; @@ -226,6 +227,7 @@ export function SettingsPanel() { + diff --git a/lib/bailian/client.ts b/lib/bailian/client.ts new file mode 100644 index 0000000..369b9bb --- /dev/null +++ b/lib/bailian/client.ts @@ -0,0 +1,158 @@ +import type { EnabledImageCapability, GenerationStatus } from "@/lib/types"; +import type { PromptMaterial } from "@/lib/prompt/assembler"; +import { toAbsoluteUrl } from "@/lib/server/runtime"; + +export const BAILIAN_DEFAULT_BASE_URL = "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"; +export const BAILIAN_IMAGE_MODEL = "wan2.7-image-pro"; +export const BAILIAN_VIDEO_MODEL = "wan2.7-i2v-2026-04-25"; + +export type BailianTaskResponse = Record & { + output?: Record; + request_id?: string; + code?: string; + message?: string; +}; + +export function getBailianConfig() { + const compatibleBaseUrl = (process.env.BAILIAN_BASE_URL || BAILIAN_DEFAULT_BASE_URL).replace(/\/+$/, ""); + return { + apiKey: process.env.BAILIAN_API_KEY?.trim() || process.env.DASHSCOPE_API_KEY?.trim(), + compatibleBaseUrl, + nativeBaseUrl: deriveBailianNativeBaseUrl(compatibleBaseUrl), + imageModel: process.env.BAILIAN_IMAGE_MODEL || BAILIAN_IMAGE_MODEL, + videoModel: process.env.BAILIAN_VIDEO_MODEL || BAILIAN_VIDEO_MODEL + }; +} + +export function deriveBailianNativeBaseUrl(baseUrl: string) { + return baseUrl.replace(/\/+$/, "").replace(/\/compatible-mode\/v1$/i, ""); +} + +export function shouldMockBailian() { + const flag = (process.env.BAILIAN_MOCK || "auto").trim().toLowerCase(); + if (flag === "1" || flag === "true") return true; + return false; +} + +export function buildBailianImagePayload(capability: EnabledImageCapability, input: Record) { + if (capability !== "image.generate") throw new Error("百炼渠道当前仅支持图片生成与参考图生图。"); + const prompt = String(input.prompt || "").trim(); + if (!prompt) throw new Error("图片生成提示词不能为空。"); + const imageUrls = stringArray(input.imageUrls); + if (imageUrls.length > 9) throw new Error("百炼参考图生图最多支持 9 张图片。"); + const width = numberValue(input.width); + const height = numberValue(input.height); + validateBailianImageSize(width, height, imageUrls.length > 0); + return { + model: getBailianConfig().imageModel, + input: { + messages: [{ + role: "user", + content: [...imageUrls.map((image) => ({ image })), { text: prompt }] + }] + }, + parameters: { + size: width && height ? `${width}*${height}` : "2K", + n: 1, + watermark: false, + ...(imageUrls.length ? {} : { thinking_mode: true }) + } + }; +} + +export function validateBailianImageSize(width?: number, height?: number, editing = false) { + if (!width && !height) return; + if (!width || !height || !Number.isInteger(width) || !Number.isInteger(height)) throw new Error("百炼图片宽高必须为整数。"); + const pixels = width * height; + const minPixels = 768 * 768; + const maxPixels = (editing ? 2048 : 4096) ** 2; + if (pixels < minPixels || pixels > maxPixels) throw new Error(`百炼${editing ? "参考图生图" : "文生图"}总像素须在 768×768 至 ${editing ? "2048×2048" : "4096×4096"} 之间。`); + const ratio = width / height; + if (ratio < 1 / 8 || ratio > 8) throw new Error("百炼图片宽高比须在 1:8 至 8:1 之间。"); +} + +export function buildBailianVideoPayload(input: { prompt: string; materials: PromptMaterial[]; origin: string; settings: Record }) { + const images = input.materials.filter((item) => item.type === "image"); + if (images.length < 1 || images.length > 2 || input.materials.some((item) => item.type !== "image")) { + throw new Error("百炼图生视频仅支持 1 张首帧图,或 2 张首尾帧图。"); + } + const duration = numberValue(input.settings.duration) ?? 10; + if (!Number.isInteger(duration) || duration < 2 || duration > 15) throw new Error("百炼视频时长须为 2–15 秒。"); + const resolution = String(input.settings.resolution || "720P").toUpperCase(); + if (resolution !== "720P" && resolution !== "1080P") throw new Error("百炼视频分辨率仅支持 720P 或 1080P。"); + return { + model: getBailianConfig().videoModel, + input: { + prompt: input.prompt, + media: images.map((item, index) => ({ + type: index === 0 ? "first_frame" : "last_frame", + url: toAbsoluteUrl(item.url, input.origin) + })) + }, + parameters: { resolution, duration, prompt_extend: true, watermark: false } + }; +} + +export async function submitBailianTask(kind: "image" | "video", payload: Record) { + const config = requiredConfig(); + const path = kind === "image" + ? "/api/v1/services/aigc/image-generation/generation" + : "/api/v1/services/aigc/video-generation/video-synthesis"; + return bailianFetch(`${config.nativeBaseUrl}${path}`, config.apiKey, { + method: "POST", + headers: { "X-DashScope-Async": "enable" }, + body: JSON.stringify(payload) + }); +} + +export async function queryBailianTask(taskId: string) { + const config = requiredConfig(); + return bailianFetch(`${config.nativeBaseUrl}/api/v1/tasks/${encodeURIComponent(taskId)}`, config.apiKey); +} + +export function bailianTaskId(response: BailianTaskResponse) { + return stringValue(response.output?.task_id) || stringValue(response.task_id); +} + +export function bailianStatus(response: BailianTaskResponse): GenerationStatus { + const value = String(response.output?.task_status || response.status || "").toUpperCase(); + if (["SUCCEEDED", "SUCCESS", "COMPLETED"].includes(value)) return "succeeded"; + if (["FAILED", "UNKNOWN"].includes(value)) return "failed"; + if (["CANCELED", "CANCELLED"].includes(value)) return "cancelled"; + if (["RUNNING", "PROCESSING"].includes(value)) return "running"; + return "queued"; +} + +export function bailianResultUrls(response: BailianTaskResponse, kind: "image" | "video") { + const output = response.output || {}; + if (kind === "video") return [stringValue(output.video_url)].filter(Boolean) as string[]; + const results = Array.isArray(output.results) ? output.results : []; + const resultUrls = results.map((item) => stringValue((item as Record)?.url)).filter(Boolean) as string[]; + const choices = Array.isArray(output.choices) ? output.choices : []; + const choiceUrls = choices.flatMap((choice) => { + const message = (choice as Record)?.message as Record | undefined; + const content = Array.isArray(message?.content) ? message.content : []; + return content.map((item) => stringValue((item as Record)?.image)).filter(Boolean) as string[]; + }); + return [...resultUrls, ...choiceUrls]; +} + +async function bailianFetch(url: string, apiKey: string, init: RequestInit = {}) { + const response = await fetch(url, { + ...init, + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...(init.headers || {}) } + }); + const json = await response.json().catch(() => ({})) as BailianTaskResponse; + if (!response.ok) throw new Error(`百炼 API 请求失败:${response.status} ${json.message || JSON.stringify(json)}`); + return json; +} + +function requiredConfig() { + const config = getBailianConfig(); + if (!config.apiKey) throw new Error("缺少 BAILIAN_API_KEY。"); + return { ...config, apiKey: config.apiKey }; +} + +function stringArray(value: unknown) { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && Boolean(item)) : []; } +function stringValue(value: unknown) { return typeof value === "string" && value ? value : undefined; } +function numberValue(value: unknown) { const number = Number(value); return Number.isFinite(number) ? number : undefined; } diff --git a/lib/evolink/image-client.ts b/lib/evolink/image-client.ts index d9559bb..0f5ec7e 100644 --- a/lib/evolink/image-client.ts +++ b/lib/evolink/image-client.ts @@ -1,6 +1,6 @@ import type { EnabledImageCapability, GenerationStatus } from "@/lib/types"; -export type ImageCreationEngine = "jimeng" | "evolink"; +export type ImageCreationEngine = "jimeng" | "evolink" | "bailian"; export type EvolinkImageSettings = { apiKey?: string; @@ -29,7 +29,7 @@ export type EvolinkTaskResponse = { export function getSelectedImageEngine(): ImageCreationEngine { const value = (process.env.IMAGE_CREATION_ENGINE || process.env.IMAGE_PROVIDER || "jimeng").trim().toLowerCase(); - return value === "evolink" ? "evolink" : "jimeng"; + return value === "evolink" || value === "bailian" ? value : "jimeng"; } export function getEffectiveImageEngine(capability: EnabledImageCapability, override?: unknown): ImageCreationEngine { @@ -44,6 +44,7 @@ export function getEffectiveImageEngine(capability: EnabledImageCapability, over export function normalizeImageEngine(value: unknown): ImageCreationEngine | undefined { const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; if (normalized === "evolink") return "evolink"; + if (normalized === "bailian") return "bailian"; if (normalized === "jimeng") return "jimeng"; return undefined; } @@ -51,6 +52,7 @@ export function normalizeImageEngine(value: unknown): ImageCreationEngine | unde function selectedEngineFrom(value: string | undefined): ImageCreationEngine { const normalized = (value || "").trim().toLowerCase(); if (normalized === "evolink") return "evolink"; + if (normalized === "bailian") return "bailian"; if (normalized === "jimeng") return "jimeng"; return getSelectedImageEngine(); } diff --git a/lib/server/app-settings.ts b/lib/server/app-settings.ts index c0ed948..726280b 100644 --- a/lib/server/app-settings.ts +++ b/lib/server/app-settings.ts @@ -8,6 +8,7 @@ import { accountManagementConfigured } from "@/lib/server/organization-client"; import { rootDir } from "@/lib/server/runtime"; import { shouldMockVisualApi } from "@/lib/volcengine/visual-client"; import type { EnabledImageCapability } from "@/lib/types"; +import { getBailianConfig, shouldMockBailian } from "@/lib/bailian/client"; export type SettingsField = { key: string; @@ -133,6 +134,18 @@ const settingDefinitions: Array<{ { key: "SEEDANCE_API_KEY", label: "方舟 API Key", secret: true, type: "password" } ] }, + { + id: "bailian", + title: "阿里云百炼 API", + description: "用于万相 2.7 文生图、参考图生图和图生视频。Base URL 会自动派生原生异步接口。", + fields: [ + { 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" }, + { key: "BAILIAN_MOCK", label: "Mock 策略", type: "select", defaultValue: "auto", options: [{ label: "自动", value: "auto" }, { label: "总是 Mock", value: "true" }, { label: "总是真实接口", value: "false" }] } + ] + }, { id: "oss", title: "OSS 资产存储", @@ -155,7 +168,18 @@ const engineFieldDefinitions: FieldDefinition[] = [ defaultValue: "jimeng", options: [ { label: "即梦 / 火山视觉", value: "jimeng" }, - { label: "EvoLink GPT Image 2", value: "evolink" } + { label: "EvoLink GPT Image 2", value: "evolink" }, + { label: "阿里云百炼 Wan 2.7", value: "bailian" } + ] + }, + { + key: "VIDEO_GENERATE_ENGINE", + label: "视频生成", + type: "select", + defaultValue: "seedance", + options: [ + { label: "Seedance", value: "seedance" }, + { label: "阿里云百炼 Wan 2.7", value: "bailian" } ] }, { @@ -197,6 +221,7 @@ export async function getApiSettings() { visual: shouldMockVisualApi() ? "mock" : "real", evolink: shouldMockEvolinkApi() ? "mock" : "real", seedance: shouldMockSeedance() ? "mock" : "real", + bailian: shouldMockBailian() ? "mock" : getBailianConfig().apiKey ? "real" : "missing", auth: authConfigSummary(auth), organization: accountManagementConfigured() ? "configured" : "missing", data: process.env.SUPABASE_SERVICE_ROLE_KEY ? "supabase" : "local" @@ -215,10 +240,10 @@ export async function getApiSettings() { }), { id: "video.generate", - label: "Seedance 视频生成", - reqKey: seedance.model, - engine: "seedance", - engineLabel: "Seedance", + label: "视频生成", + reqKey: engineAssignments.find((item) => item.id === "video.generate")?.reqKey || seedance.model, + engine: engineAssignments.find((item) => item.id === "video.generate")?.engine || "seedance", + engineLabel: engineAssignments.find((item) => item.id === "video.generate")?.engineLabel || "Seedance", enabled: true } ], @@ -252,15 +277,16 @@ function buildEngineAssignments(fileEnv: Map): EngineAssignment[ const capabilities = getJimengCapabilities(); const evolink = getEvolinkImageSettings(); const seedance = getSeedanceConfig(); + const bailian = getBailianConfig(); const generateEngine = currentEngineValue(engineFieldDefinitions[0], fileEnv); - const inpaintEngine = currentEngineValue(engineFieldDefinitions[1], fileEnv); + const inpaintEngine = currentEngineValue(engineFieldDefinitions[2], fileEnv); return [ imageEngineAssignment({ capability: "image.generate", label: "图片生成", engine: generateEngine, - reqKey: generateEngine === "evolink" ? evolink.model : capabilities["image.generate"].reqKey, + reqKey: generateEngine === "bailian" ? bailian.imageModel : generateEngine === "evolink" ? evolink.model : capabilities["image.generate"].reqKey, field: engineFieldDefinitions[0], fileEnv }), @@ -269,7 +295,7 @@ function buildEngineAssignments(fileEnv: Map): EngineAssignment[ label: "局部重绘", engine: inpaintEngine, reqKey: inpaintEngine === "evolink" ? evolink.model : capabilities["image.inpaint"].reqKey, - field: engineFieldDefinitions[1], + field: engineFieldDefinitions[2], fileEnv }), { @@ -285,12 +311,13 @@ function buildEngineAssignments(fileEnv: Map): EngineAssignment[ { id: "video.generate", label: "视频生成", - engine: "seedance", - engineLabel: "Seedance", - mode: shouldMockSeedance() ? "mock" : "seedance", - modeLabel: shouldMockSeedance() ? "Mock" : "Seedance 真实接口", - reqKey: seedance.model, - configurable: false + engine: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv), + engineLabel: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv) === "bailian" ? "阿里云百炼" : "Seedance", + mode: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv) === "bailian" ? (shouldMockBailian() ? "mock" : "bailian") : (shouldMockSeedance() ? "mock" : "seedance"), + modeLabel: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv) === "bailian" ? (shouldMockBailian() ? "Mock" : "百炼真实接口") : (shouldMockSeedance() ? "Mock" : "Seedance 真实接口"), + reqKey: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv) === "bailian" ? bailian.videoModel : seedance.model, + configurable: true, + field: fieldWithValue(engineFieldDefinitions[1], currentVideoEngineValue(engineFieldDefinitions[1], fileEnv), isConfigured(engineFieldDefinitions[1].key, fileEnv)) } ]; } @@ -336,21 +363,27 @@ function isConfigured(key: string, fileEnv: Map) { } function normalizeImageEngine(value: string | undefined): ImageCreationEngine { - return value?.trim().toLowerCase() === "evolink" ? "evolink" : "jimeng"; + const normalized = value?.trim().toLowerCase(); + return normalized === "evolink" || normalized === "bailian" ? normalized : "jimeng"; } function imageEngineLabel(engine: ImageCreationEngine) { - return engine === "evolink" ? "EvoLink" : "即梦"; + return engine === "evolink" ? "EvoLink" : engine === "bailian" ? "阿里云百炼" : "即梦"; } function modeForImageEngine(engine: ImageCreationEngine) { if (engine === "evolink") return shouldMockEvolinkApi() ? "mock" : "evolink"; + if (engine === "bailian") return shouldMockBailian() ? "mock" : "bailian"; return shouldMockVisualApi() ? "mock" : "volcengine"; } function modeLabel(mode: string, engine: ImageCreationEngine) { if (mode === "mock") return "Mock"; - return engine === "evolink" ? "EvoLink 真实接口" : "即梦真实接口"; + return engine === "evolink" ? "EvoLink 真实接口" : engine === "bailian" ? "百炼真实接口" : "即梦真实接口"; +} + +function currentVideoEngineValue(field: FieldDefinition, fileEnv: Map) { + return (process.env[field.key] ?? fileEnv.get(field.key) ?? field.defaultValue) === "bailian" ? "bailian" : "seedance"; } function envFilePath() { diff --git a/lib/server/data-store.ts b/lib/server/data-store.ts index ea8863d..496a1e0 100644 --- a/lib/server/data-store.ts +++ b/lib/server/data-store.ts @@ -679,7 +679,7 @@ function imageTemplateFromRow(row: Record): ImageTemplate { prompt: String(row.prompt || ""), previewImageUrl: optionalString(row.preview_image_url), settings: isRecord(row.settings) ? { - engine: row.settings.engine === "jimeng" || row.settings.engine === "evolink" ? row.settings.engine : undefined, + engine: row.settings.engine === "jimeng" || row.settings.engine === "evolink" || row.settings.engine === "bailian" ? row.settings.engine : undefined, width: optionalNumber(row.settings.width), height: optionalNumber(row.settings.height), forceSingle: typeof row.settings.forceSingle === "boolean" ? row.settings.forceSingle : undefined, diff --git a/lib/server/generation-service.ts b/lib/server/generation-service.ts index 3099d33..a97a157 100644 --- a/lib/server/generation-service.ts +++ b/lib/server/generation-service.ts @@ -28,6 +28,16 @@ import { importRemoteImageAsAsset, saveGeneratedAsset } from "@/lib/server/stora import { DEFAULT_OWNER_ID, toAbsoluteUrl } from "@/lib/server/runtime"; import type { EnabledImageCapability, GenerationJob, VisualTaskQueryResponse } from "@/lib/types"; import { queryVisualTask, shouldMockVisualApi, submitVisualTask } from "@/lib/volcengine/visual-client"; +import { + bailianResultUrls, + bailianStatus, + bailianTaskId, + buildBailianImagePayload, + getBailianConfig, + queryBailianTask, + shouldMockBailian, + submitBailianTask +} from "@/lib/bailian/client"; export type SubmitImageJobInput = { ownerId?: string; @@ -59,18 +69,22 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string) const capability = getEnabledImageCapability(input.capability); const normalizedUrls = (input.imageUrls || []).map((url) => toAbsoluteUrl(url, origin)); const engine = getEffectiveImageEngine(input.capability, input.engine); - const providerPayload = engine === "evolink" - ? buildEvolinkImagePayload(input.capability, { ...input, imageUrls: normalizedUrls }) - : buildJimengPayload(input.capability, capability.reqKey, { ...input, imageUrls: normalizedUrls }); - const mock = engine === "evolink" ? shouldMockEvolinkApi() : shouldMockVisualApi(); - const reqKey = engine === "evolink" ? getEvolinkImageSettings().model : capability.reqKey; + const providerPayload = engine === "bailian" + ? buildBailianImagePayload(input.capability, { ...input, imageUrls: normalizedUrls }) + : engine === "evolink" + ? buildEvolinkImagePayload(input.capability, { ...input, imageUrls: normalizedUrls }) + : buildJimengPayload(input.capability, capability.reqKey, { ...input, imageUrls: normalizedUrls }); + const bailianConfig = getBailianConfig(); + const mock = engine === "bailian" ? shouldMockBailian() : engine === "evolink" ? shouldMockEvolinkApi() : shouldMockVisualApi(); + const missingBailianKey = engine === "bailian" && !mock && !bailianConfig.apiKey; + const reqKey = engine === "bailian" ? bailianConfig.imageModel : engine === "evolink" ? getEvolinkImageSettings().model : capability.reqKey; let job = await createGenerationJob({ ownerId, externalClientId: input.externalClientId, capability: input.capability, - provider: mock ? "mock" : engine === "evolink" ? "evolink" : "volcengine-visual", + provider: mock ? "mock" : engine === "bailian" ? "bailian" : engine === "evolink" ? "evolink" : "volcengine-visual", reqKey, - status: "queued", + status: missingBailianKey ? "failed" : "queued", prompt: input.prompt, inputAssetIds: input.inputAssetIds || [], inputUrls: normalizedUrls, @@ -80,6 +94,7 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string) input, providerPayload }, + error: missingBailianKey ? { message: "缺少 BAILIAN_API_KEY,请先在设置页配置阿里云百炼 API Key。", retryable: true } : undefined, retryOf: input.retryOf, idempotencyKey: input.idempotencyKey, idempotencyFingerprint: input.idempotencyFingerprint, @@ -103,6 +118,12 @@ export async function advanceImageJob(jobId: string, origin: string): Promise { const providerPayload = asRecord(job.requestPayload.providerPayload); try { + if (job.provider === "bailian") { + const response = await submitBailianTask("image", providerPayload); + const taskId = bailianTaskId(response); + if (!taskId) throw new Error(response.message || "百炼图片任务响应缺少 task_id。"); + return updateGenerationJob(job.id, { status: "queued", providerTaskId: taskId, responsePayload: response }); + } if (job.provider === "evolink") { const response = await submitEvolinkImageTask(providerPayload); const taskId = getEvolinkTaskId(response); @@ -164,6 +185,7 @@ export async function syncImageJob(jobId: string, origin: string): Promise { + const response = await queryBailianTask(job.providerTaskId!); + const status = bailianStatus(response); + if (status !== "succeeded") { + return updateGenerationJob(job.id, { + status, + responsePayload: response, + error: status === "failed" ? { message: response.message || "百炼图片任务失败。", retryable: false } : undefined + }); + } + const urls = bailianResultUrls(response, "image"); + if (!urls.length) return updateGenerationJob(job.id, { status: "failed", responsePayload: response, error: { message: "百炼图片任务未返回结果地址。", retryable: true } }); + const assets = []; + for (let index = 0; index < urls.length; index += 1) { + assets.push(await importRemoteImageAsAsset({ ownerId: job.ownerId, url: urls[index], origin, source: sourceForCapability(job.capability), capability: job.capability, jobId: job.id, index, tags: assetTagsForJob(job) })); + } + await recordUsageEvent({ ownerId: job.ownerId, jobId: job.id, capability: job.capability, quantity: assets.length, estimatedUnit: "image" }); + return updateGenerationJob(job.id, { status: "succeeded", outputAssetIds: assets.map((asset) => asset.id), responsePayload: response }); +} + async function syncEvolinkImageJob(job: GenerationJob, origin: string): Promise { if (!job.providerTaskId) return job; let response: EvolinkTaskResponse; diff --git a/lib/server/image-template-input.ts b/lib/server/image-template-input.ts index c28c49a..4d3bfc0 100644 --- a/lib/server/image-template-input.ts +++ b/lib/server/image-template-input.ts @@ -89,7 +89,7 @@ function optionalScale(value: unknown): number | undefined { function normalizeImageEngine(value: unknown): ImageTemplate["settings"]["engine"] | undefined { const text = optionalText(value, 20); - if (text === "jimeng" || text === "evolink") return text; + if (text === "jimeng" || text === "evolink" || text === "bailian") return text; return undefined; } diff --git a/lib/server/public-api-jobs.ts b/lib/server/public-api-jobs.ts index 433aaed..08791ac 100644 --- a/lib/server/public-api-jobs.ts +++ b/lib/server/public-api-jobs.ts @@ -5,6 +5,8 @@ import { submitImageJob, type SubmitImageJobInput } from "@/lib/server/generatio import { submitVideoJob, type SubmitVideoJobInput } from "@/lib/server/video-generation-service"; import { publicApiOwnerId, type PublicApiClient } from "@/lib/server/public-api-auth"; import type { EnabledImageCapability, GenerationCapability, GenerationJob } from "@/lib/types"; +import type { ImageCreationEngine } from "@/lib/evolink/image-client"; +import type { VideoCreationEngine } from "@/lib/server/video-generation-service"; export class PublicApiConflictError extends Error { status = 409; @@ -36,6 +38,7 @@ export type PublicJobCreateBody = { resolution?: "4k" | "8k"; quality?: string; seed?: number; + engine?: ImageCreationEngine | VideoCreationEngine; }; export async function createPublicGenerationJob(input: { @@ -92,6 +95,7 @@ export async function createPublicGenerationJob(input: { const job = await submitImageJob({ ...common, capability: imageCapability, + engine: input.body.engine === "bailian" || input.body.engine === "evolink" || input.body.engine === "jimeng" ? input.body.engine : undefined, prompt: input.body.prompt || assembled?.prompt, imageUrls: input.body.imageUrls || input.body.inputUrls || materialImages, inputAssetIds: input.body.inputAssetIds || (input.body.materials || []).map((material) => material.id).filter(Boolean) as string[], diff --git a/lib/server/video-generation-service.ts b/lib/server/video-generation-service.ts index ca6036c..105c086 100644 --- a/lib/server/video-generation-service.ts +++ b/lib/server/video-generation-service.ts @@ -11,12 +11,16 @@ import { importRemoteAssetAsAsset } from "@/lib/server/storage"; import { createSeedanceTask, getSeedanceConfig, querySeedanceTask, shouldMockSeedance, type SeedanceSettings } from "@/lib/seedance/client"; import type { GenerationJob } from "@/lib/types"; import { normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution } from "@/lib/video-settings"; +import { bailianResultUrls, bailianStatus, bailianTaskId, buildBailianVideoPayload, getBailianConfig, queryBailianTask, shouldMockBailian, submitBailianTask } from "@/lib/bailian/client"; + +export type VideoCreationEngine = "seedance" | "bailian"; export type SubmitVideoJobInput = PromptAssemblyInput & { ownerId?: string; externalClientId?: string; prompt?: string; settings?: SeedanceSettings; + engine?: VideoCreationEngine; materials?: PromptMaterial[]; retryOf?: string; idempotencyKey?: string; @@ -28,6 +32,7 @@ export type SubmitVideoJobInput = PromptAssemblyInput & { export async function submitVideoJob(input: SubmitVideoJobInput, origin: string): Promise { const ownerId = input.ownerId || DEFAULT_OWNER_ID; + const engine: VideoCreationEngine = input.engine === "bailian" ? "bailian" : "seedance"; const config = getSeedanceConfig(); const assembled = assemblePrompt({ ...input, @@ -36,17 +41,24 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string) }); const finalPrompt = input.prompt?.trim() || assembled.prompt; const settings: SeedanceSettings = { ...(input.settings || {}) }; - settings.ratio = normalizeVideoRatio(settings.ratio, config.ratio); - settings.duration = normalizeVideoDuration(settings.duration) ?? config.duration; - settings.resolution = normalizeVideoResolution(settings.resolution, config.model, config.resolution); - const mock = shouldMockSeedance(); + if (engine === "bailian") { + settings.duration = normalizeVideoDuration(settings.duration) ?? 10; + settings.resolution = String(settings.resolution || "720P").toUpperCase(); + buildBailianVideoPayload({ prompt: finalPrompt, materials: assembled.materials, origin, settings }); + } else { + settings.ratio = normalizeVideoRatio(settings.ratio, config.ratio); + settings.duration = normalizeVideoDuration(settings.duration) ?? config.duration; + settings.resolution = normalizeVideoResolution(settings.resolution, config.model, config.resolution); + } + const mock = engine === "bailian" ? shouldMockBailian() : shouldMockSeedance(); + const missingBailianKey = engine === "bailian" && !mock && !getBailianConfig().apiKey; let job = await createGenerationJob({ ownerId, externalClientId: input.externalClientId, capability: "video.generate", - provider: mock ? "mock" : "seedance", - reqKey: config.model, - status: "queued", + provider: mock ? "mock" : engine, + reqKey: engine === "bailian" ? getBailianConfig().videoModel : config.model, + status: missingBailianKey ? "failed" : "queued", prompt: finalPrompt, inputAssetIds: input.materials?.map((material) => material.id).filter(Boolean) as string[] || [], inputUrls: assembled.materials.map((material) => material.url), @@ -54,8 +66,10 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string) requestPayload: { input, assembled, - settings + settings, + engine }, + error: missingBailianKey ? { message: "缺少 BAILIAN_API_KEY,请先在设置页配置阿里云百炼 API Key。", retryable: true } : undefined, retryOf: input.retryOf, idempotencyKey: input.idempotencyKey, idempotencyFingerprint: input.idempotencyFingerprint, @@ -84,17 +98,14 @@ async function dispatchVideoJob(job: GenerationJob, origin: string): Promise) { const asset = await importRemoteAssetAsAsset({ ownerId: job.ownerId, - url: result.resultUrl, + url: resultUrl, origin, source: "generated", capability: "video.generate", @@ -144,17 +177,8 @@ export async function syncVideoJob(jobId: string, origin: string): Promise { diff --git a/lib/types.ts b/lib/types.ts index cc535ae..5c910f2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -46,7 +46,7 @@ export type GenerationJob = { ownerId: string; externalClientId?: string; capability: GenerationCapability; - provider: "volcengine-visual" | "evolink" | "seedance" | "mock"; + provider: "volcengine-visual" | "evolink" | "seedance" | "bailian" | "mock"; reqKey: string; status: GenerationStatus; prompt?: string; @@ -101,7 +101,7 @@ export type Project = { }; export type ImageTemplateSettings = { - engine?: "jimeng" | "evolink"; + engine?: "jimeng" | "evolink" | "bailian"; width?: number; height?: number; forceSingle?: boolean; diff --git a/tests/bailian-client.test.ts b/tests/bailian-client.test.ts new file mode 100644 index 0000000..d55929b --- /dev/null +++ b/tests/bailian-client.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + bailianResultUrls, + bailianStatus, + buildBailianImagePayload, + buildBailianVideoPayload, + deriveBailianNativeBaseUrl, + shouldMockBailian +} from "@/lib/bailian/client"; + +afterEach(() => { + delete process.env.BAILIAN_IMAGE_MODEL; + delete process.env.BAILIAN_VIDEO_MODEL; + delete process.env.BAILIAN_API_KEY; + delete process.env.DASHSCOPE_API_KEY; + delete process.env.BAILIAN_MOCK; +}); + +describe("Bailian client", () => { + it("derives native API base from compatible-mode URL", () => { + expect(deriveBailianNativeBaseUrl("https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1")) + .toBe("https://workspace.cn-beijing.maas.aliyuncs.com"); + }); + + it("does not silently mock when the API key is missing", () => { + expect(shouldMockBailian()).toBe(false); + process.env.BAILIAN_MOCK = "true"; + expect(shouldMockBailian()).toBe(true); + }); + + it("builds Wan 2.7 text-to-image and reference-image payloads", () => { + const text = buildBailianImagePayload("image.generate", { prompt: "海边酒店", width: 2048, height: 2048 }); + expect(text).toMatchObject({ model: "wan2.7-image-pro", parameters: { size: "2048*2048", thinking_mode: true, watermark: false } }); + const edit = buildBailianImagePayload("image.generate", { prompt: "保持人物一致", imageUrls: ["https://example.com/a.png"], width: 2048, height: 2048 }); + expect((edit.input.messages[0].content as Array>)[0]).toEqual({ image: "https://example.com/a.png" }); + expect(edit.parameters).not.toHaveProperty("thinking_mode"); + }); + + it("preserves a user-selected 9:16 output size for reference images", () => { + const payload = buildBailianImagePayload("image.generate", { + prompt: "x", + imageUrls: ["https://example.com/a.png"], + width: 1440, + height: 2560 + }); + expect(payload.parameters.size).toBe("1440*2560"); + }); + + it("rejects reference-image output only when total pixels exceed the 2K budget", () => { + expect(() => buildBailianImagePayload("image.generate", { prompt: "x", imageUrls: ["https://example.com/a.png"], width: 2048, height: 2560 })) + .toThrow("总像素"); + }); + + it("maps one or two video images to first and last frames", () => { + const payload = buildBailianVideoPayload({ + prompt: "镜头推进", + origin: "https://app.example.com", + materials: [{ type: "image", url: "/first.png" }, { type: "image", url: "/last.png" }], + settings: { duration: 10, resolution: "1080P" } + }); + expect(payload.input.media).toEqual([ + { type: "first_frame", url: "https://app.example.com/first.png" }, + { type: "last_frame", url: "https://app.example.com/last.png" } + ]); + expect(payload.parameters).toMatchObject({ duration: 10, resolution: "1080P", prompt_extend: true, watermark: false }); + }); + + it("normalizes task state and result URLs", () => { + const response = { output: { task_status: "SUCCEEDED", results: [{ url: "https://example.com/result.png" }] } }; + expect(bailianStatus(response)).toBe("succeeded"); + expect(bailianResultUrls(response, "image")).toEqual(["https://example.com/result.png"]); + }); + + it("extracts Wan 2.7 image URLs from choices content", () => { + const response = { output: { task_status: "SUCCEEDED", choices: [{ message: { content: [{ type: "image", image: "https://example.com/choice.png" }] } }] } }; + expect(bailianResultUrls(response, "image")).toEqual(["https://example.com/choice.png"]); + }); +});