feat: add Bailian image generation support
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
智能超清仍走即梦超清能力。
|
智能超清仍走即梦超清能力。
|
||||||
|
|
||||||
|
|||||||
@@ -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 张参考图生图,以及 1–2 张首尾帧图生视频
|
||||||
|
|
||||||
### 图片编辑
|
### 图片编辑
|
||||||
|
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
158
lib/bailian/client.ts
Normal 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("百炼视频时长须为 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<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; }
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
78
tests/bailian-client.test.ts
Normal file
78
tests/bailian-client.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user