feat: add Bailian image generation support

This commit is contained in:
inman
2026-07-14 12:58:50 +08:00
parent 9a3070c749
commit f642b5e71f
18 changed files with 464 additions and 74 deletions

View File

@@ -111,6 +111,7 @@ https://你的域名/api/auth/callback
- `jimeng`:默认引擎,走火山 Visual 即梦能力。 - `jimeng`:默认引擎,走火山 Visual 即梦能力。
- `evolink`:走 EvoLink GPT Image 2 中转站,提交任务后轮询 EvoLink task 结果。 - `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.
智能超清仍走即梦超清能力。 智能超清仍走即梦超清能力。

View File

@@ -201,6 +201,7 @@ https://你的域名/api/auth/callback
- `jimeng`:火山 Visual 即梦能力 - `jimeng`:火山 Visual 即梦能力
- `evolink`EvoLink GPT Image 2 中转接口 - `evolink`EvoLink GPT Image 2 中转接口
- `bailian`:阿里云百炼万相 2.7,支持文生图、最多 9 张参考图生图,以及 12 张首尾帧图生视频
### 图片编辑 ### 图片编辑
@@ -296,6 +297,11 @@ cp .env.example .env.local
| `ZHINIAN_WORKER_*` | Worker 间隔、批量、锁超时、重试配置 | | `ZHINIAN_WORKER_*` | Worker 间隔、批量、锁超时、重试配置 |
| `IMAGE_GENERATE_ENGINE` | 图片生成引擎:`jimeng``evolink` | | `IMAGE_GENERATE_ENGINE` | 图片生成引擎:`jimeng``evolink` |
| `IMAGE_INPAINT_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_ACCESS_KEY_ID` | 火山引擎 Access Key |
| `VOLCENGINE_SECRET_ACCESS_KEY` | 火山引擎 Secret Key | | `VOLCENGINE_SECRET_ACCESS_KEY` | 火山引擎 Secret Key |
| `EVOLINK_API_KEY` | EvoLink API Key | | `EVOLINK_API_KEY` | EvoLink API Key |

View File

@@ -4,12 +4,14 @@ import { getEffectiveImageEngine, getEvolinkImageSettings, shouldMockEvolinkApi
import { getVisibleImageCapabilities } from "@/lib/jimeng/capabilities"; import { getVisibleImageCapabilities } from "@/lib/jimeng/capabilities";
import { shouldMockVisualApi } from "@/lib/volcengine/visual-client"; import { shouldMockVisualApi } from "@/lib/volcengine/visual-client";
import { getSeedanceConfig, shouldMockSeedance } from "@/lib/seedance/client"; import { getSeedanceConfig, shouldMockSeedance } from "@/lib/seedance/client";
import { getBailianConfig, shouldMockBailian } from "@/lib/bailian/client";
export const runtime = "nodejs"; export const runtime = "nodejs";
export async function GET() { export async function GET() {
const evolink = getEvolinkImageSettings(); const evolink = getEvolinkImageSettings();
const auth = getAuthRuntimeConfig(); const auth = getAuthRuntimeConfig();
const bailian = getBailianConfig();
return jsonOk({ return jsonOk({
ok: true, ok: true,
appId: "zhinian-web-studio", appId: "zhinian-web-studio",
@@ -17,6 +19,7 @@ export async function GET() {
visualApiMode: shouldMockVisualApi() ? "mock" : "volcengine", visualApiMode: shouldMockVisualApi() ? "mock" : "volcengine",
evolinkMode: shouldMockEvolinkApi() ? "mock" : "evolink", evolinkMode: shouldMockEvolinkApi() ? "mock" : "evolink",
seedanceMode: shouldMockSeedance() ? "mock" : "seedance", seedanceMode: shouldMockSeedance() ? "mock" : "seedance",
bailianMode: shouldMockBailian() ? "mock" : bailian.apiKey ? "bailian" : "missing",
authMode: authConfigSummary(auth), authMode: authConfigSummary(auth),
capabilities: [ capabilities: [
...getVisibleImageCapabilities().map((capability) => { ...getVisibleImageCapabilities().map((capability) => {
@@ -25,8 +28,8 @@ export async function GET() {
id: capability.id, id: capability.id,
label: capability.label, label: capability.label,
engine, engine,
engineLabel: engine === "evolink" ? "EvoLink" : "即梦", engineLabel: engine === "evolink" ? "EvoLink" : engine === "bailian" ? "阿里云百炼" : "即梦",
reqKey: engine === "evolink" ? evolink.model : capability.reqKey reqKey: engine === "bailian" ? bailian.imageModel : engine === "evolink" ? evolink.model : capability.reqKey
}; };
}), }),
{ {

View File

@@ -4,6 +4,7 @@ import { jsonOk } from "@/lib/server/api";
import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth";
import { publicApiError } from "@/lib/server/public-api-response"; import { publicApiError } from "@/lib/server/public-api-response";
import { getSeedanceConfig } from "@/lib/seedance/client"; import { getSeedanceConfig } from "@/lib/seedance/client";
import { getBailianConfig } from "@/lib/bailian/client";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -11,6 +12,7 @@ export async function GET(request: Request) {
try { try {
authenticatePublicApiRequest(request); authenticatePublicApiRequest(request);
const evolink = getEvolinkImageSettings(); const evolink = getEvolinkImageSettings();
const bailian = getBailianConfig();
return jsonOk({ return jsonOk({
capabilities: [ capabilities: [
...getVisibleImageCapabilities().map((capability) => { ...getVisibleImageCapabilities().map((capability) => {
@@ -20,8 +22,8 @@ export async function GET(request: Request) {
label: capability.label, label: capability.label,
kind: "image", kind: "image",
engine, engine,
provider: engine === "evolink" ? "evolink" : "volcengine-visual", provider: engine === "bailian" ? "bailian" : engine === "evolink" ? "evolink" : "volcengine-visual",
reqKey: engine === "evolink" ? evolink.model : capability.reqKey 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"], ratios: ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"],
resolutions: ["480p", "720p", "1080p"] 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"]
}
} }
] ]
}); });

View File

@@ -53,7 +53,7 @@ export async function GET(request: Request) {
ownerId: { type: "string", example: "api:partner-a" }, ownerId: { type: "string", example: "api:partner-a" },
externalClientId: { type: "string" }, externalClientId: { type: "string" },
capability: { $ref: "#/components/schemas/GenerationCapability" }, 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" }, reqKey: { type: "string" },
status: { $ref: "#/components/schemas/GenerationStatus" }, status: { $ref: "#/components/schemas/GenerationStatus" },
prompt: { type: "string" }, prompt: { type: "string" },

View File

@@ -470,6 +470,7 @@ function providerLabel(provider: GenerationJob["provider"]) {
if (provider === "volcengine-visual") return "火山视觉"; if (provider === "volcengine-visual") return "火山视觉";
if (provider === "evolink") return "EvoLink"; if (provider === "evolink") return "EvoLink";
if (provider === "seedance") return "Seedance"; if (provider === "seedance") return "Seedance";
if (provider === "bailian") return "阿里云百炼";
return "Mock"; return "Mock";
} }

View File

@@ -21,7 +21,8 @@ import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders"
type GenerateMode = "image" | "video"; type GenerateMode = "image" | "video";
type StudioMode = GenerateMode | ImageEditMode; type StudioMode = GenerateMode | ImageEditMode;
type MaterialKind = PromptMaterial["type"]; type MaterialKind = PromptMaterial["type"];
type ImageGenerateEngine = "jimeng" | "evolink"; type ImageGenerateEngine = "jimeng" | "evolink" | "bailian";
type VideoGenerateEngine = "seedance" | "bailian";
type MentionState = { type MentionState = {
start: number; start: number;
@@ -154,6 +155,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
const [imageEngine, setImageEngine] = useState<ImageGenerateEngine>("jimeng"); const [imageEngine, setImageEngine] = useState<ImageGenerateEngine>("jimeng");
const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id); const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id);
const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id); const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id);
const [videoEngine, setVideoEngine] = useState<VideoGenerateEngine>("seedance");
const [videoRatio, setVideoRatio] = useState("9:16"); const [videoRatio, setVideoRatio] = useState("9:16");
const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT); const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT);
const [videoResolution, setVideoResolution] = useState("720p"); const [videoResolution, setVideoResolution] = useState("720p");
@@ -247,7 +249,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
.then((response) => response.ok ? response.json() : null) .then((response) => response.ok ? response.json() : null)
.then((payload: { capabilities?: HealthCapability[] } | null) => { .then((payload: { capabilities?: HealthCapability[] } | null) => {
const engine = payload?.capabilities?.find((capability) => capability.id === "image.generate")?.engine; 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); .catch(() => undefined);
return () => { return () => {
@@ -743,6 +745,12 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
setNotice(null); setNotice(null);
try { try {
const endpoint = generateMode === "image" ? "/api/generations/image" : "/api/generations/video"; 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" const body = generateMode === "image"
? { ? {
capability: "image.generate", capability: "image.generate",
@@ -757,12 +765,13 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
force_single: true force_single: true
} }
: { : {
engine: videoEngine,
prompt, prompt,
materials, materials,
settings: { settings: {
ratio: videoRatio, ...(videoEngine === "seedance" ? { ratio: videoRatio } : {}),
duration: videoDuration, duration: videoDuration,
resolution: videoResolution resolution: videoEngine === "bailian" ? videoResolution.toUpperCase() : videoResolution
} }
}; };
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
@@ -1056,6 +1065,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
<select id="imageEngine" value={imageEngine} onChange={(event) => setImageEngine(normalizeImageEngine(event.target.value) || "jimeng")}> <select id="imageEngine" value={imageEngine} onChange={(event) => setImageEngine(normalizeImageEngine(event.target.value) || "jimeng")}>
<option value="jimeng"></option> <option value="jimeng"></option>
<option value="evolink">Image2</option> <option value="evolink">Image2</option>
<option value="bailian"></option>
</select> </select>
</div> </div>
<div className="field inline-field"> <div className="field inline-field">
@@ -1069,11 +1079,13 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
</select> </select>
</div> </div>
<div className="field inline-field"> <div className="field inline-field">
<label htmlFor="imageEngineTuning">{imageEngine === "evolink" ? "生成质量" : "文本影响"}</label> <label htmlFor="imageEngineTuning">{imageEngine === "evolink" ? "生成质量" : imageEngine === "bailian" ? "生成模式" : "文本影响"}</label>
{imageEngine === "evolink" ? ( {imageEngine === "evolink" ? (
<select id="imageEngineTuning" value={evolinkQuality} onChange={(event) => setEvolinkQuality(event.target.value)}> <select id="imageEngineTuning" value={evolinkQuality} onChange={(event) => setEvolinkQuality(event.target.value)}>
{evolinkQualityOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)} {evolinkQualityOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select> </select>
) : imageEngine === "bailian" ? (
<select id="imageEngineTuning" value="thinking" disabled><option value="thinking"></option></select>
) : ( ) : (
<select id="imageEngineTuning" value={jimengInfluence} onChange={(event) => setJimengInfluence(event.target.value)}> <select id="imageEngineTuning" value={jimengInfluence} onChange={(event) => setJimengInfluence(event.target.value)}>
{jimengInfluenceOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)} {jimengInfluenceOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
@@ -1083,6 +1095,14 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
</> </>
) : ( ) : (
<> <>
<div className="field inline-field">
<label htmlFor="videoEngine"></label>
<select id="videoEngine" value={videoEngine} onChange={(event) => setVideoEngine(event.target.value === "bailian" ? "bailian" : "seedance")}>
<option value="seedance">Seedance</option>
<option value="bailian"></option>
</select>
</div>
{videoEngine === "seedance" ? (
<div className="field inline-field"> <div className="field inline-field">
<label htmlFor="videoRatio"></label> <label htmlFor="videoRatio"></label>
<select id="videoRatio" value={videoRatio} onChange={(event) => setVideoRatio(event.target.value)}> <select id="videoRatio" value={videoRatio} onChange={(event) => setVideoRatio(event.target.value)}>
@@ -1091,6 +1111,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
))} ))}
</select> </select>
</div> </div>
) : null}
<div className="field inline-field"> <div className="field inline-field">
<label htmlFor="videoDuration"></label> <label htmlFor="videoDuration"></label>
<select <select
@@ -1098,13 +1119,13 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
value={videoDuration} value={videoDuration}
onChange={(event) => setVideoDuration(clampVideoDuration(event.target.value, videoDuration, { allowAuto: false }))} onChange={(event) => setVideoDuration(clampVideoDuration(event.target.value, videoDuration, { allowAuto: false }))}
> >
{VIDEO_DURATION_OPTIONS.map((seconds) => <option key={seconds} value={seconds}>{seconds} </option>)} {(videoEngine === "bailian" ? Array.from({ length: 14 }, (_, index) => index + 2) : VIDEO_DURATION_OPTIONS).map((seconds) => <option key={seconds} value={seconds}>{seconds} </option>)}
</select> </select>
</div> </div>
<div className="field inline-field"> <div className="field inline-field">
<label htmlFor="videoResolution"></label> <label htmlFor="videoResolution"></label>
<select id="videoResolution" value={videoResolution} onChange={(event) => setVideoResolution(event.target.value)}> <select id="videoResolution" value={videoResolution} onChange={(event) => setVideoResolution(event.target.value)}>
{VIDEO_RESOLUTIONS.map((resolution) => <option key={resolution} value={resolution}>{resolution}</option>)} {(videoEngine === "bailian" ? ["720p", "1080p"] : VIDEO_RESOLUTIONS).map((resolution) => <option key={resolution} value={resolution}>{resolution}</option>)}
</select> </select>
</div> </div>
</> </>
@@ -1537,12 +1558,12 @@ function sizeLabelFromSettings(width?: number, height?: number) {
} }
function normalizeImageEngine(value?: string): ImageGenerateEngine | undefined { function normalizeImageEngine(value?: string): ImageGenerateEngine | undefined {
if (value === "jimeng" || value === "evolink") return value; if (value === "jimeng" || value === "evolink" || value === "bailian") return value;
return undefined; return undefined;
} }
function imageEngineLabel(engine: ImageGenerateEngine) { function imageEngineLabel(engine: ImageGenerateEngine) {
return engine === "evolink" ? "Image2" : "即梦"; return engine === "evolink" ? "Image2" : engine === "bailian" ? "百炼" : "即梦";
} }
function sortTemplates(a: ImageTemplate, b: ImageTemplate) { function sortTemplates(a: ImageTemplate, b: ImageTemplate) {

View File

@@ -28,6 +28,7 @@ type SettingsPayload = {
visual: string; visual: string;
evolink: string; evolink: string;
seedance: string; seedance: string;
bailian: string;
auth: string; auth: string;
organization: string; organization: string;
data: string; data: string;
@@ -226,6 +227,7 @@ export function SettingsPanel() {
<ServiceBadge label="即梦" value={payload?.modes.visual === "real" ? "真实接口" : "Mock"} ready={payload?.modes.visual === "real"} /> <ServiceBadge label="即梦" value={payload?.modes.visual === "real" ? "真实接口" : "Mock"} ready={payload?.modes.visual === "real"} />
<ServiceBadge label="EvoLink" value={payload?.modes.evolink === "real" ? "真实接口" : "Mock"} ready={payload?.modes.evolink === "real"} /> <ServiceBadge label="EvoLink" value={payload?.modes.evolink === "real" ? "真实接口" : "Mock"} ready={payload?.modes.evolink === "real"} />
<ServiceBadge label="Seedance" value={payload?.modes.seedance === "real" ? "真实接口" : "Mock"} ready={payload?.modes.seedance === "real"} /> <ServiceBadge label="Seedance" value={payload?.modes.seedance === "real" ? "真实接口" : "Mock"} ready={payload?.modes.seedance === "real"} />
<ServiceBadge label="百炼" value={payload?.modes.bailian === "real" ? "真实接口" : payload?.modes.bailian === "mock" ? "Mock" : "未配置"} ready={payload?.modes.bailian === "real"} />
<ServiceBadge label="账户" value={authModeLabel(payload?.modes.auth)} ready={payload?.modes.auth === "configured"} /> <ServiceBadge label="账户" value={authModeLabel(payload?.modes.auth)} ready={payload?.modes.auth === "configured"} />
<ServiceBadge label="组织" value={payload?.modes.organization === "configured" ? "已配置" : "待配置"} ready={payload?.modes.organization === "configured"} /> <ServiceBadge label="组织" value={payload?.modes.organization === "configured" ? "已配置" : "待配置"} ready={payload?.modes.organization === "configured"} />
<ServiceBadge label="数据层" value={payload?.modes.data === "supabase" ? "Supabase" : "本地 JSON"} ready={false} /> <ServiceBadge label="数据层" value={payload?.modes.data === "supabase" ? "Supabase" : "本地 JSON"} ready={false} />

158
lib/bailian/client.ts Normal file
View File

@@ -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<string, unknown> & {
output?: Record<string, unknown>;
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<string, unknown>) {
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<string, unknown> }) {
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("百炼视频时长须为 215 秒。");
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<string, unknown>) {
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<string, unknown>)?.url)).filter(Boolean) as string[];
const choices = Array.isArray(output.choices) ? output.choices : [];
const choiceUrls = choices.flatMap((choice) => {
const message = (choice as Record<string, unknown>)?.message as Record<string, unknown> | undefined;
const content = Array.isArray(message?.content) ? message.content : [];
return content.map((item) => stringValue((item as Record<string, unknown>)?.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; }

View File

@@ -1,6 +1,6 @@
import type { EnabledImageCapability, GenerationStatus } from "@/lib/types"; import type { EnabledImageCapability, GenerationStatus } from "@/lib/types";
export type ImageCreationEngine = "jimeng" | "evolink"; export type ImageCreationEngine = "jimeng" | "evolink" | "bailian";
export type EvolinkImageSettings = { export type EvolinkImageSettings = {
apiKey?: string; apiKey?: string;
@@ -29,7 +29,7 @@ export type EvolinkTaskResponse = {
export function getSelectedImageEngine(): ImageCreationEngine { export function getSelectedImageEngine(): ImageCreationEngine {
const value = (process.env.IMAGE_CREATION_ENGINE || process.env.IMAGE_PROVIDER || "jimeng").trim().toLowerCase(); 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 { 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 { export function normalizeImageEngine(value: unknown): ImageCreationEngine | undefined {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "evolink") return "evolink"; if (normalized === "evolink") return "evolink";
if (normalized === "bailian") return "bailian";
if (normalized === "jimeng") return "jimeng"; if (normalized === "jimeng") return "jimeng";
return undefined; return undefined;
} }
@@ -51,6 +52,7 @@ export function normalizeImageEngine(value: unknown): ImageCreationEngine | unde
function selectedEngineFrom(value: string | undefined): ImageCreationEngine { function selectedEngineFrom(value: string | undefined): ImageCreationEngine {
const normalized = (value || "").trim().toLowerCase(); const normalized = (value || "").trim().toLowerCase();
if (normalized === "evolink") return "evolink"; if (normalized === "evolink") return "evolink";
if (normalized === "bailian") return "bailian";
if (normalized === "jimeng") return "jimeng"; if (normalized === "jimeng") return "jimeng";
return getSelectedImageEngine(); return getSelectedImageEngine();
} }

View File

@@ -8,6 +8,7 @@ import { accountManagementConfigured } from "@/lib/server/organization-client";
import { rootDir } from "@/lib/server/runtime"; import { rootDir } from "@/lib/server/runtime";
import { shouldMockVisualApi } from "@/lib/volcengine/visual-client"; import { shouldMockVisualApi } from "@/lib/volcengine/visual-client";
import type { EnabledImageCapability } from "@/lib/types"; import type { EnabledImageCapability } from "@/lib/types";
import { getBailianConfig, shouldMockBailian } from "@/lib/bailian/client";
export type SettingsField = { export type SettingsField = {
key: string; key: string;
@@ -133,6 +134,18 @@ const settingDefinitions: Array<{
{ key: "SEEDANCE_API_KEY", label: "方舟 API Key", secret: true, type: "password" } { 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", id: "oss",
title: "OSS 资产存储", title: "OSS 资产存储",
@@ -155,7 +168,18 @@ const engineFieldDefinitions: FieldDefinition[] = [
defaultValue: "jimeng", defaultValue: "jimeng",
options: [ options: [
{ label: "即梦 / 火山视觉", value: "jimeng" }, { 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", visual: shouldMockVisualApi() ? "mock" : "real",
evolink: shouldMockEvolinkApi() ? "mock" : "real", evolink: shouldMockEvolinkApi() ? "mock" : "real",
seedance: shouldMockSeedance() ? "mock" : "real", seedance: shouldMockSeedance() ? "mock" : "real",
bailian: shouldMockBailian() ? "mock" : getBailianConfig().apiKey ? "real" : "missing",
auth: authConfigSummary(auth), auth: authConfigSummary(auth),
organization: accountManagementConfigured() ? "configured" : "missing", organization: accountManagementConfigured() ? "configured" : "missing",
data: process.env.SUPABASE_SERVICE_ROLE_KEY ? "supabase" : "local" data: process.env.SUPABASE_SERVICE_ROLE_KEY ? "supabase" : "local"
@@ -215,10 +240,10 @@ export async function getApiSettings() {
}), }),
{ {
id: "video.generate", id: "video.generate",
label: "Seedance 视频生成", label: "视频生成",
reqKey: seedance.model, reqKey: engineAssignments.find((item) => item.id === "video.generate")?.reqKey || seedance.model,
engine: "seedance", engine: engineAssignments.find((item) => item.id === "video.generate")?.engine || "seedance",
engineLabel: "Seedance", engineLabel: engineAssignments.find((item) => item.id === "video.generate")?.engineLabel || "Seedance",
enabled: true enabled: true
} }
], ],
@@ -252,15 +277,16 @@ function buildEngineAssignments(fileEnv: Map<string, string>): EngineAssignment[
const capabilities = getJimengCapabilities(); const capabilities = getJimengCapabilities();
const evolink = getEvolinkImageSettings(); const evolink = getEvolinkImageSettings();
const seedance = getSeedanceConfig(); const seedance = getSeedanceConfig();
const bailian = getBailianConfig();
const generateEngine = currentEngineValue(engineFieldDefinitions[0], fileEnv); const generateEngine = currentEngineValue(engineFieldDefinitions[0], fileEnv);
const inpaintEngine = currentEngineValue(engineFieldDefinitions[1], fileEnv); const inpaintEngine = currentEngineValue(engineFieldDefinitions[2], fileEnv);
return [ return [
imageEngineAssignment({ imageEngineAssignment({
capability: "image.generate", capability: "image.generate",
label: "图片生成", label: "图片生成",
engine: generateEngine, 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], field: engineFieldDefinitions[0],
fileEnv fileEnv
}), }),
@@ -269,7 +295,7 @@ function buildEngineAssignments(fileEnv: Map<string, string>): EngineAssignment[
label: "局部重绘", label: "局部重绘",
engine: inpaintEngine, engine: inpaintEngine,
reqKey: inpaintEngine === "evolink" ? evolink.model : capabilities["image.inpaint"].reqKey, reqKey: inpaintEngine === "evolink" ? evolink.model : capabilities["image.inpaint"].reqKey,
field: engineFieldDefinitions[1], field: engineFieldDefinitions[2],
fileEnv fileEnv
}), }),
{ {
@@ -285,12 +311,13 @@ function buildEngineAssignments(fileEnv: Map<string, string>): EngineAssignment[
{ {
id: "video.generate", id: "video.generate",
label: "视频生成", label: "视频生成",
engine: "seedance", engine: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv),
engineLabel: "Seedance", engineLabel: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv) === "bailian" ? "阿里云百炼" : "Seedance",
mode: shouldMockSeedance() ? "mock" : "seedance", mode: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv) === "bailian" ? (shouldMockBailian() ? "mock" : "bailian") : (shouldMockSeedance() ? "mock" : "seedance"),
modeLabel: shouldMockSeedance() ? "Mock" : "Seedance 真实接口", modeLabel: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv) === "bailian" ? (shouldMockBailian() ? "Mock" : "百炼真实接口") : (shouldMockSeedance() ? "Mock" : "Seedance 真实接口"),
reqKey: seedance.model, reqKey: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv) === "bailian" ? bailian.videoModel : seedance.model,
configurable: false 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<string, string>) {
} }
function normalizeImageEngine(value: string | undefined): ImageCreationEngine { 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) { function imageEngineLabel(engine: ImageCreationEngine) {
return engine === "evolink" ? "EvoLink" : "即梦"; return engine === "evolink" ? "EvoLink" : engine === "bailian" ? "阿里云百炼" : "即梦";
} }
function modeForImageEngine(engine: ImageCreationEngine) { function modeForImageEngine(engine: ImageCreationEngine) {
if (engine === "evolink") return shouldMockEvolinkApi() ? "mock" : "evolink"; if (engine === "evolink") return shouldMockEvolinkApi() ? "mock" : "evolink";
if (engine === "bailian") return shouldMockBailian() ? "mock" : "bailian";
return shouldMockVisualApi() ? "mock" : "volcengine"; return shouldMockVisualApi() ? "mock" : "volcengine";
} }
function modeLabel(mode: string, engine: ImageCreationEngine) { function modeLabel(mode: string, engine: ImageCreationEngine) {
if (mode === "mock") return "Mock"; if (mode === "mock") return "Mock";
return engine === "evolink" ? "EvoLink 真实接口" : "即梦真实接口"; return engine === "evolink" ? "EvoLink 真实接口" : engine === "bailian" ? "百炼真实接口" : "即梦真实接口";
}
function currentVideoEngineValue(field: FieldDefinition, fileEnv: Map<string, string>) {
return (process.env[field.key] ?? fileEnv.get(field.key) ?? field.defaultValue) === "bailian" ? "bailian" : "seedance";
} }
function envFilePath() { function envFilePath() {

View File

@@ -679,7 +679,7 @@ function imageTemplateFromRow(row: Record<string, unknown>): ImageTemplate {
prompt: String(row.prompt || ""), prompt: String(row.prompt || ""),
previewImageUrl: optionalString(row.preview_image_url), previewImageUrl: optionalString(row.preview_image_url),
settings: isRecord(row.settings) ? { 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), width: optionalNumber(row.settings.width),
height: optionalNumber(row.settings.height), height: optionalNumber(row.settings.height),
forceSingle: typeof row.settings.forceSingle === "boolean" ? row.settings.forceSingle : undefined, forceSingle: typeof row.settings.forceSingle === "boolean" ? row.settings.forceSingle : undefined,

View File

@@ -28,6 +28,16 @@ import { importRemoteImageAsAsset, saveGeneratedAsset } from "@/lib/server/stora
import { DEFAULT_OWNER_ID, toAbsoluteUrl } from "@/lib/server/runtime"; import { DEFAULT_OWNER_ID, toAbsoluteUrl } from "@/lib/server/runtime";
import type { EnabledImageCapability, GenerationJob, VisualTaskQueryResponse } from "@/lib/types"; import type { EnabledImageCapability, GenerationJob, VisualTaskQueryResponse } from "@/lib/types";
import { queryVisualTask, shouldMockVisualApi, submitVisualTask } from "@/lib/volcengine/visual-client"; 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 = { export type SubmitImageJobInput = {
ownerId?: string; ownerId?: string;
@@ -59,18 +69,22 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string)
const capability = getEnabledImageCapability(input.capability); const capability = getEnabledImageCapability(input.capability);
const normalizedUrls = (input.imageUrls || []).map((url) => toAbsoluteUrl(url, origin)); const normalizedUrls = (input.imageUrls || []).map((url) => toAbsoluteUrl(url, origin));
const engine = getEffectiveImageEngine(input.capability, input.engine); const engine = getEffectiveImageEngine(input.capability, input.engine);
const providerPayload = engine === "evolink" const providerPayload = engine === "bailian"
? buildEvolinkImagePayload(input.capability, { ...input, imageUrls: normalizedUrls }) ? buildBailianImagePayload(input.capability, { ...input, imageUrls: normalizedUrls })
: buildJimengPayload(input.capability, capability.reqKey, { ...input, imageUrls: normalizedUrls }); : engine === "evolink"
const mock = engine === "evolink" ? shouldMockEvolinkApi() : shouldMockVisualApi(); ? buildEvolinkImagePayload(input.capability, { ...input, imageUrls: normalizedUrls })
const reqKey = engine === "evolink" ? getEvolinkImageSettings().model : capability.reqKey; : 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({ let job = await createGenerationJob({
ownerId, ownerId,
externalClientId: input.externalClientId, externalClientId: input.externalClientId,
capability: input.capability, capability: input.capability,
provider: mock ? "mock" : engine === "evolink" ? "evolink" : "volcengine-visual", provider: mock ? "mock" : engine === "bailian" ? "bailian" : engine === "evolink" ? "evolink" : "volcengine-visual",
reqKey, reqKey,
status: "queued", status: missingBailianKey ? "failed" : "queued",
prompt: input.prompt, prompt: input.prompt,
inputAssetIds: input.inputAssetIds || [], inputAssetIds: input.inputAssetIds || [],
inputUrls: normalizedUrls, inputUrls: normalizedUrls,
@@ -80,6 +94,7 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string)
input, input,
providerPayload providerPayload
}, },
error: missingBailianKey ? { message: "缺少 BAILIAN_API_KEY请先在设置页配置阿里云百炼 API Key。", retryable: true } : undefined,
retryOf: input.retryOf, retryOf: input.retryOf,
idempotencyKey: input.idempotencyKey, idempotencyKey: input.idempotencyKey,
idempotencyFingerprint: input.idempotencyFingerprint, idempotencyFingerprint: input.idempotencyFingerprint,
@@ -103,6 +118,12 @@ export async function advanceImageJob(jobId: string, origin: string): Promise<Ge
async function dispatchImageJob(job: GenerationJob): Promise<GenerationJob> { async function dispatchImageJob(job: GenerationJob): Promise<GenerationJob> {
const providerPayload = asRecord(job.requestPayload.providerPayload); const providerPayload = asRecord(job.requestPayload.providerPayload);
try { 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") { if (job.provider === "evolink") {
const response = await submitEvolinkImageTask(providerPayload); const response = await submitEvolinkImageTask(providerPayload);
const taskId = getEvolinkTaskId(response); const taskId = getEvolinkTaskId(response);
@@ -164,6 +185,7 @@ export async function syncImageJob(jobId: string, origin: string): Promise<Gener
if (job.provider === "evolink") { if (job.provider === "evolink") {
return syncEvolinkImageJob(job, origin); return syncEvolinkImageJob(job, origin);
} }
if (job.provider === "bailian") return syncBailianImageJob(job, origin);
const queryPayload = buildJimengQueryPayload(job.reqKey, job.providerTaskId); const queryPayload = buildJimengQueryPayload(job.reqKey, job.providerTaskId);
let response: VisualTaskQueryResponse; let response: VisualTaskQueryResponse;
@@ -238,6 +260,26 @@ export async function syncImageJob(jobId: string, origin: string): Promise<Gener
}); });
} }
async function syncBailianImageJob(job: GenerationJob, origin: string): Promise<GenerationJob> {
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<GenerationJob> { async function syncEvolinkImageJob(job: GenerationJob, origin: string): Promise<GenerationJob> {
if (!job.providerTaskId) return job; if (!job.providerTaskId) return job;
let response: EvolinkTaskResponse; let response: EvolinkTaskResponse;

View File

@@ -89,7 +89,7 @@ function optionalScale(value: unknown): number | undefined {
function normalizeImageEngine(value: unknown): ImageTemplate["settings"]["engine"] | undefined { function normalizeImageEngine(value: unknown): ImageTemplate["settings"]["engine"] | undefined {
const text = optionalText(value, 20); const text = optionalText(value, 20);
if (text === "jimeng" || text === "evolink") return text; if (text === "jimeng" || text === "evolink" || text === "bailian") return text;
return undefined; return undefined;
} }

View File

@@ -5,6 +5,8 @@ import { submitImageJob, type SubmitImageJobInput } from "@/lib/server/generatio
import { submitVideoJob, type SubmitVideoJobInput } from "@/lib/server/video-generation-service"; import { submitVideoJob, type SubmitVideoJobInput } from "@/lib/server/video-generation-service";
import { publicApiOwnerId, type PublicApiClient } from "@/lib/server/public-api-auth"; import { publicApiOwnerId, type PublicApiClient } from "@/lib/server/public-api-auth";
import type { EnabledImageCapability, GenerationCapability, GenerationJob } from "@/lib/types"; 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 { export class PublicApiConflictError extends Error {
status = 409; status = 409;
@@ -36,6 +38,7 @@ export type PublicJobCreateBody = {
resolution?: "4k" | "8k"; resolution?: "4k" | "8k";
quality?: string; quality?: string;
seed?: number; seed?: number;
engine?: ImageCreationEngine | VideoCreationEngine;
}; };
export async function createPublicGenerationJob(input: { export async function createPublicGenerationJob(input: {
@@ -92,6 +95,7 @@ export async function createPublicGenerationJob(input: {
const job = await submitImageJob({ const job = await submitImageJob({
...common, ...common,
capability: imageCapability, 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, prompt: input.body.prompt || assembled?.prompt,
imageUrls: input.body.imageUrls || input.body.inputUrls || materialImages, imageUrls: input.body.imageUrls || input.body.inputUrls || materialImages,
inputAssetIds: input.body.inputAssetIds || (input.body.materials || []).map((material) => material.id).filter(Boolean) as string[], inputAssetIds: input.body.inputAssetIds || (input.body.materials || []).map((material) => material.id).filter(Boolean) as string[],

View File

@@ -11,12 +11,16 @@ import { importRemoteAssetAsAsset } from "@/lib/server/storage";
import { createSeedanceTask, getSeedanceConfig, querySeedanceTask, shouldMockSeedance, type SeedanceSettings } from "@/lib/seedance/client"; import { createSeedanceTask, getSeedanceConfig, querySeedanceTask, shouldMockSeedance, type SeedanceSettings } from "@/lib/seedance/client";
import type { GenerationJob } from "@/lib/types"; import type { GenerationJob } from "@/lib/types";
import { normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution } from "@/lib/video-settings"; 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 & { export type SubmitVideoJobInput = PromptAssemblyInput & {
ownerId?: string; ownerId?: string;
externalClientId?: string; externalClientId?: string;
prompt?: string; prompt?: string;
settings?: SeedanceSettings; settings?: SeedanceSettings;
engine?: VideoCreationEngine;
materials?: PromptMaterial[]; materials?: PromptMaterial[];
retryOf?: string; retryOf?: string;
idempotencyKey?: string; idempotencyKey?: string;
@@ -28,6 +32,7 @@ export type SubmitVideoJobInput = PromptAssemblyInput & {
export async function submitVideoJob(input: SubmitVideoJobInput, origin: string): Promise<GenerationJob> { export async function submitVideoJob(input: SubmitVideoJobInput, origin: string): Promise<GenerationJob> {
const ownerId = input.ownerId || DEFAULT_OWNER_ID; const ownerId = input.ownerId || DEFAULT_OWNER_ID;
const engine: VideoCreationEngine = input.engine === "bailian" ? "bailian" : "seedance";
const config = getSeedanceConfig(); const config = getSeedanceConfig();
const assembled = assemblePrompt({ const assembled = assemblePrompt({
...input, ...input,
@@ -36,17 +41,24 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string)
}); });
const finalPrompt = input.prompt?.trim() || assembled.prompt; const finalPrompt = input.prompt?.trim() || assembled.prompt;
const settings: SeedanceSettings = { ...(input.settings || {}) }; const settings: SeedanceSettings = { ...(input.settings || {}) };
settings.ratio = normalizeVideoRatio(settings.ratio, config.ratio); if (engine === "bailian") {
settings.duration = normalizeVideoDuration(settings.duration) ?? config.duration; settings.duration = normalizeVideoDuration(settings.duration) ?? 10;
settings.resolution = normalizeVideoResolution(settings.resolution, config.model, config.resolution); settings.resolution = String(settings.resolution || "720P").toUpperCase();
const mock = shouldMockSeedance(); 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({ let job = await createGenerationJob({
ownerId, ownerId,
externalClientId: input.externalClientId, externalClientId: input.externalClientId,
capability: "video.generate", capability: "video.generate",
provider: mock ? "mock" : "seedance", provider: mock ? "mock" : engine,
reqKey: config.model, reqKey: engine === "bailian" ? getBailianConfig().videoModel : config.model,
status: "queued", status: missingBailianKey ? "failed" : "queued",
prompt: finalPrompt, prompt: finalPrompt,
inputAssetIds: input.materials?.map((material) => material.id).filter(Boolean) as string[] || [], inputAssetIds: input.materials?.map((material) => material.id).filter(Boolean) as string[] || [],
inputUrls: assembled.materials.map((material) => material.url), inputUrls: assembled.materials.map((material) => material.url),
@@ -54,8 +66,10 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string)
requestPayload: { requestPayload: {
input, input,
assembled, assembled,
settings settings,
engine
}, },
error: missingBailianKey ? { message: "缺少 BAILIAN_API_KEY请先在设置页配置阿里云百炼 API Key。", retryable: true } : undefined,
retryOf: input.retryOf, retryOf: input.retryOf,
idempotencyKey: input.idempotencyKey, idempotencyKey: input.idempotencyKey,
idempotencyFingerprint: input.idempotencyFingerprint, idempotencyFingerprint: input.idempotencyFingerprint,
@@ -84,17 +98,14 @@ async function dispatchVideoJob(job: GenerationJob, origin: string): Promise<Gen
const materials = Array.isArray(assembled.materials) const materials = Array.isArray(assembled.materials)
? assembled.materials as PromptMaterial[] ? assembled.materials as PromptMaterial[]
: input.materials || []; : input.materials || [];
const response = await createSeedanceTask({ if (job.provider === "bailian") {
prompt: job.prompt || "", const response = await submitBailianTask("video", buildBailianVideoPayload({ prompt: job.prompt || "", settings: asRecord(settings), materials, origin }));
settings, const providerTaskId = bailianTaskId(response);
materials, if (!providerTaskId) throw new Error("视频任务响应缺少 task_id。");
origin return updateGenerationJob(job.id, { status: "running", providerTaskId, responsePayload: response });
}); }
return updateGenerationJob(job.id, { const response = await createSeedanceTask({ prompt: job.prompt || "", settings, materials, origin });
status: "running", return updateGenerationJob(job.id, { status: "running", providerTaskId: response.providerTaskId, responsePayload: response.raw });
providerTaskId: response.providerTaskId,
responsePayload: response.raw
});
} catch (error) { } catch (error) {
job = await updateGenerationJob(job.id, { job = await updateGenerationJob(job.id, {
status: "failed", status: "failed",
@@ -115,6 +126,15 @@ export async function syncVideoJob(jobId: string, origin: string): Promise<Gener
if (!job.providerTaskId) return job; if (!job.providerTaskId) return job;
try { try {
if (job.provider === "bailian") {
const response = await queryBailianTask(job.providerTaskId);
const status = bailianStatus(response);
const resultUrl = bailianResultUrls(response, "video")[0];
if (status !== "succeeded" || !resultUrl) {
return updateGenerationJob(job.id, { status, responsePayload: response, error: status === "failed" ? { message: response.message || "百炼视频任务失败。", retryable: false } : undefined });
}
return completeRemoteVideo(job, origin, resultUrl, response);
}
const result = await querySeedanceTask(job.providerTaskId); const result = await querySeedanceTask(job.providerTaskId);
if (result.status !== "succeeded" || !result.resultUrl) { if (result.status !== "succeeded" || !result.resultUrl) {
return updateGenerationJob(job.id, { return updateGenerationJob(job.id, {
@@ -123,9 +143,22 @@ export async function syncVideoJob(jobId: string, origin: string): Promise<Gener
error: result.errorMessage ? { message: result.errorMessage, retryable: result.status === "failed" } : undefined error: result.errorMessage ? { message: result.errorMessage, retryable: result.status === "failed" } : undefined
}); });
} }
return completeRemoteVideo(job, origin, result.resultUrl, result.raw);
} catch (error) {
return updateGenerationJob(job.id, {
status: "failed",
error: {
message: error instanceof Error ? error.message : String(error),
retryable: false
}
});
}
}
async function completeRemoteVideo(job: GenerationJob, origin: string, resultUrl: string, responsePayload: Record<string, unknown>) {
const asset = await importRemoteAssetAsAsset({ const asset = await importRemoteAssetAsAsset({
ownerId: job.ownerId, ownerId: job.ownerId,
url: result.resultUrl, url: resultUrl,
origin, origin,
source: "generated", source: "generated",
capability: "video.generate", capability: "video.generate",
@@ -144,17 +177,8 @@ export async function syncVideoJob(jobId: string, origin: string): Promise<Gener
return updateGenerationJob(job.id, { return updateGenerationJob(job.id, {
status: "succeeded", status: "succeeded",
outputAssetIds: [asset.id], outputAssetIds: [asset.id],
responsePayload: result.raw responsePayload
}); });
} catch (error) {
return updateGenerationJob(job.id, {
status: "failed",
error: {
message: error instanceof Error ? error.message : String(error),
retryable: false
}
});
}
} }
export async function retryVideoJob(jobId: string, origin: string, ownerId?: string): Promise<GenerationJob> { export async function retryVideoJob(jobId: string, origin: string, ownerId?: string): Promise<GenerationJob> {

View File

@@ -46,7 +46,7 @@ export type GenerationJob = {
ownerId: string; ownerId: string;
externalClientId?: string; externalClientId?: string;
capability: GenerationCapability; capability: GenerationCapability;
provider: "volcengine-visual" | "evolink" | "seedance" | "mock"; provider: "volcengine-visual" | "evolink" | "seedance" | "bailian" | "mock";
reqKey: string; reqKey: string;
status: GenerationStatus; status: GenerationStatus;
prompt?: string; prompt?: string;
@@ -101,7 +101,7 @@ export type Project = {
}; };
export type ImageTemplateSettings = { export type ImageTemplateSettings = {
engine?: "jimeng" | "evolink"; engine?: "jimeng" | "evolink" | "bailian";
width?: number; width?: number;
height?: number; height?: number;
forceSingle?: boolean; forceSingle?: boolean;

View File

@@ -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<Record<string, string>>)[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"]);
});
});