若干更新迭代

This commit is contained in:
andy committed 2026-10-02 19:56:32 +08:00
1 parent deb9cc5bcf
commit 4ac786aafb
64 files changed
+3251 -291

No files matched your search

+35 -9
View File
@@ -13,6 +13,17 @@ export type BailianTaskResponse = Record<string, unknown> & {
message?: string;
};
export function bailianOutputVideoSeconds(response: BailianTaskResponse): number | undefined {
const usage = response.usage && typeof response.usage === "object" && !Array.isArray(response.usage)
? response.usage as Record<string, unknown>
: {};
for (const value of [usage.output_video_duration, usage.duration]) {
const seconds = typeof value === "number" ? value : NaN;
if (Number.isFinite(seconds) && seconds > 0) return seconds;
}
return undefined;
}
export function getBailianConfig() {
const compatibleBaseUrl = (process.env.BAILIAN_BASE_URL || BAILIAN_DEFAULT_BASE_URL).replace(/\/+$/, "");
return {
@@ -65,25 +76,40 @@ export function validateBailianImageSize(width?: number, height?: number, editin
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> }) {
export function buildBailianVideoPayload(input: { prompt: string; materials: PromptMaterial[]; origin: string; settings: Record<string, unknown>; model?: string }) {
const model = input.model || getBailianConfig().videoModel;
const wan30 = model === "wan3.0-video";
const mode = String(input.settings.inputMode || (wan30 ? "reference" : "frames"));
if (mode !== "frames" && mode !== "reference") throw new Error("百炼请选择首尾帧或多图参考模式。");
if (!wan30 && mode === "reference") throw new Error("Wan 2.7 仅支持首尾帧;多图参考请选择 Wan 3.0。");
const images = input.materials.filter((item) => item.type === "image");
if (images.length < 1 || images.length > 2 || input.materials.some((item) => item.type !== "image")) {
if (input.materials.some((item) => item.type !== "image")) {
throw new Error("百炼当前视频模式仅支持图片素材,请移除视频或音频。");
}
if (mode === "reference" && images.length > 10) {
throw new Error("Wan 3.0 多图参考模式最多支持 10 张图片。");
}
if (mode === "frames" && (images.length < 1 || images.length > 2)) {
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 秒。");
if (!Number.isInteger(duration) || duration < 2 || duration > (wan30 ? 30 : 15)) throw new Error(`百炼视频时长须为 2–${wan30 ? 30 : 15} 秒。`);
const resolution = String(input.settings.resolution || "720P").toUpperCase();
if (resolution !== "720P" && resolution !== "1080P") throw new Error("百炼视频分辨率仅支持 720P 或 1080P。");
if (!["720P", "1080P", ...(wan30 ? ["480P"] : [])].includes(resolution)) throw new Error("百炼视频分辨率不支持当前设置。");
const ratio = input.settings.ratio === undefined ? undefined : String(input.settings.ratio);
if (wan30 && ratio !== undefined && !["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(ratio)) {
throw new Error("Wan 3.0 画面比例支持自适应、21:9、16:9、4:3、1:1、3:4 或 9:16。");
}
return {
model: getBailianConfig().videoModel,
model,
input: {
prompt: input.prompt,
media: images.map((item, index) => ({
type: index === 0 ? "first_frame" : "last_frame",
...(images.length > 0 ? { media: images.map((item, index) => ({
type: mode === "reference" ? "reference_image" : index === 0 ? "first_frame" : "last_frame",
url: toAbsoluteUrl(item.url, input.origin)
}))
})) } : {})
},
parameters: { resolution, duration, prompt_extend: true, watermark: false }
parameters: { resolution, duration, prompt_extend: true, watermark: false, ...(wan30 && ratio ? { ratio } : {}) }
};
}
+24
View File
@@ -0,0 +1,24 @@
export type OrganizationBalance = {
organization: { id: string; name: string };
wallet: { organizationId: string; balanceFen: number; updatedAt: string };
};
export async function fetchOrganizationBalance(organizationId: string, signal?: AbortSignal): Promise<OrganizationBalance> {
const response = await fetch("/api/billing/balance", { cache: "no-store", signal });
const payload = await response.json().catch(() => null);
if (response.status === 401 || response.status === 403 || response.status === 422) {
throw new Error("登录状态或组织信息已变更,请刷新页面后重试。");
}
if (!response.ok) throw new Error("余额暂时无法读取,请重试。");
if (typeof payload?.organization?.id === "string" && payload.organization.id !== organizationId) {
throw new Error("账号所属组织已变更,请刷新页面后重试。");
}
if (!payload || payload.organization?.id !== organizationId
|| typeof payload.organization?.name !== "string"
|| payload.wallet?.organizationId !== organizationId
|| !Number.isSafeInteger(payload.wallet?.balanceFen)
|| typeof payload.wallet?.updatedAt !== "string") {
throw new Error("余额信息不完整,请刷新后重试。");
}
return payload as OrganizationBalance;
}
+52
View File
@@ -0,0 +1,52 @@
import type { GenerationJob } from "@/lib/types";
export type ProviderErrorDiagnostic = {
status?: number;
code?: string;
requestId?: string;
detail?: string;
phase?: "submit" | "query";
kind?: string;
};
const unknownSubmission = "provider submission outcome is unknown; refusing duplicate submission";
function nonemptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
export function providerErrorDiagnostic(job: GenerationJob): ProviderErrorDiagnostic | null {
const value = job.responsePayload?.providerError;
if (!value || typeof value !== "object" || Array.isArray(value)) {
const legacyCode = job.error?.code;
if (typeof legacyCode === "string" && /^[A-Za-z0-9_.:-]{1,160}$/.test(legacyCode)) return { code: legacyCode };
if (typeof legacyCode === "number" && Number.isSafeInteger(legacyCode)) return { code: String(legacyCode) };
return null;
}
const source = value as Record<string, unknown>;
const status = typeof source.status === "number" && Number.isInteger(source.status) && source.status >= 100 && source.status <= 599
? source.status : undefined;
const phase = source.phase === "submit" || source.phase === "query" ? source.phase : undefined;
const diagnostic: ProviderErrorDiagnostic = {
status,
code: nonemptyString(source.code),
requestId: nonemptyString(source.requestId),
detail: nonemptyString(source.detail),
phase,
kind: nonemptyString(source.kind)
};
return Object.values(diagnostic).some((item) => item !== undefined) ? diagnostic : null;
}
export function generationErrorMessage(job: GenerationJob): string {
const message = job.error?.message?.trim();
const providerCode = providerErrorDiagnostic(job)?.code || job.error?.code;
if (providerCode === "InputImageSensitiveContentDetected.PrivacyInformation" &&
(!message || message === unknownSubmission)) {
return "参考图片涉及真人肖像,未通过服务商审核。请更换符合服务商要求的素材后重试。";
}
if (message === unknownSubmission) {
return "暂时无法确认服务商是否已接收本次请求,为避免重复生成和扣费,平台已停止自动重试。请联系管理员核查任务状态。";
}
return message || "生成失败,服务商未提供具体原因,请联系管理员。";
}
+17 -6
View File
@@ -9,13 +9,14 @@ export type MinimaxH3Settings = {
ratio?: string;
duration?: number;
resolution?: string;
inputMode?: "frames" | "reference";
};
export type MinimaxH3Usage = {
totalSeconds: number;
inputSeconds: number;
outputSeconds: number;
inputImageCount: number;
inputImageCount?: number;
};
export function getMinimaxConfig() {
@@ -34,9 +35,17 @@ export function buildMinimaxH3Payload(input: {
}) {
const prompt = input.prompt.trim();
if (!prompt) throw new Error("MiniMax H3 视频提示词不能为空。");
if (input.materials.length > 1 || input.materials.some((material) => material.type !== "image")) {
const mode = input.settings.inputMode || "frames";
if (mode !== "frames" && mode !== "reference") throw new Error("MiniMax 请选择首帧或多图参考模式。");
if (input.materials.some((material) => material.type !== "image")) {
throw new Error("MiniMax H3 第一版支持纯文字生成,或只上传 1 张图片作为首帧。");
}
if (mode === "reference" && input.materials.length > 9) {
throw new Error("MiniMax 多图参考模式最多支持 9 张图片。");
}
if (mode === "frames" && input.materials.length > 1) {
throw new Error("MiniMax 首帧模式最多只能上传 1 张图片;多张图片请切换到多图参考模式。");
}
const duration = Number(input.settings.duration ?? 5);
if (!Number.isInteger(duration) || duration < 4 || duration > 15) {
throw new Error("MiniMax H3 视频时长须为 4–15 秒整数。");
@@ -45,8 +54,8 @@ export function buildMinimaxH3Payload(input: {
if (resolution !== "768P" && resolution !== "2K") {
throw new Error("MiniMax H3 分辨率仅支持 768P 或 2K。");
}
const ratio = input.materials.length === 1 ? "adaptive" : String(input.settings.ratio || "16:9");
if (input.materials.length === 0 && !["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(ratio)) {
const ratio = mode === "frames" && input.materials.length === 1 ? "adaptive" : String(input.settings.ratio || "16:9");
if ((input.materials.length === 0 && ratio === "adaptive") || !["21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "adaptive"].includes(ratio)) {
throw new Error("MiniMax H3 文生视频须选择固定画面比例。");
}
return {
@@ -56,7 +65,7 @@ export function buildMinimaxH3Payload(input: {
...input.materials.map((material) => ({
type: "image_url",
image_url: { url: toAbsoluteUrl(material.url, input.origin) },
role: "first_frame"
role: mode === "reference" ? "reference_image" : "first_frame"
}))
],
resolution,
@@ -100,7 +109,9 @@ export function extractMinimaxH3Usage(value: unknown): MinimaxH3Usage | undefine
totalSeconds: nonNegativeInteger(usage.total_seconds),
inputSeconds: nonNegativeInteger(usage.input_seconds),
outputSeconds,
inputImageCount: nonNegativeInteger(usage.input_image_count)
...(typeof usage.input_image_count === "number" && Number.isInteger(usage.input_image_count) && usage.input_image_count >= 0
? { inputImageCount: usage.input_image_count }
: {})
};
}
+15 -3
View File
@@ -17,6 +17,7 @@ export type SeedanceSettings = {
ratio?: string;
duration?: number;
resolution?: string;
inputMode?: "frames" | "reference";
};
export type SeedanceCreateInput = {
@@ -59,9 +60,7 @@ export async function createSeedanceTask(input: SeedanceCreateInput) {
}
const model = normalizeSeedanceModel(input.model, config.model);
const materialContent = materialContentForProvider(input.materials, input.origin);
if (!isSeedance25Model(model) && materialContent.length > 4) {
throw new Error("Seedance 2.0 最多支持 4 个素材(加上文本后 content 最多 5 项)。");
}
if (!isSeedance25Model(model)) validateSeedance20Materials(input.materials);
if (isSeedance25Model(model)) validateSeedance25Materials(input.materials);
const payload = {
model,
@@ -105,6 +104,19 @@ function validateSeedance25Materials(materials: PromptMaterial[]) {
}
}
function validateSeedance20Materials(materials: PromptMaterial[]) {
const counts = materials.reduce((result, material) => {
result[material.type] += 1;
return result;
}, { image: 0, video: 0, audio: 0 });
if (counts.image > 9 || counts.video > 3 || counts.audio > 3) {
throw new Error("Seedance 2.0 最多支持 9 张图片、3 个视频、3 个音频。");
}
if (counts.audio > 0 && counts.image + counts.video === 0) {
throw new Error("Seedance 2.0 音频不能单独使用,请添加图片或视频素材。");
}
}
export async function querySeedanceTask(providerTaskId: string): Promise<SeedanceQueryResult> {
const config = getSeedanceConfig();
if (!config.apiKey) throw new Error("缺少 SEEDANCE_API_KEY,无法查询真实生成任务。");
+3 -3
View File
@@ -125,7 +125,7 @@ const settingDefinitions: Array<{
{
id: "bailian",
title: "阿里云百炼 API",
description: "用于万相 2.7 文生图、参考图生图和图生视频。Base URL 会自动派生原生异步接口。",
description: "支持万相 2.7 图片与首尾帧视频、万相 3.0 多图参考视频。请使用与模型开通地域一致的 API Key 和 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" },
@@ -136,7 +136,7 @@ const settingDefinitions: Array<{
{
id: "minimax",
title: "MiniMax API",
description: "用于 MiniMax H3 文生视频与单首帧图生视频。",
description: "支持 MiniMax H3 文生视频、单首帧视频和最多 9 张图片的多图参考视频。",
fields: [
{ key: "MINIMAX_API_KEY", label: "MiniMax API Key", secret: true, type: "password" },
{ key: "MINIMAX_BASE_URL", label: "Base URL", defaultValue: "https://api.minimax.cn" }
@@ -177,7 +177,7 @@ const engineFieldDefinitions: FieldDefinition[] = [
options: [
{ label: "Seedance", value: "seedance" },
{ label: "MiniMax H3", value: "minimax" },
{ label: "阿里云百炼 Wan 2.7", value: "bailian" }
{ label: "阿里云百炼 Wan 2.7 / 3.0", value: "bailian" }
]
},
];
+19
View File
@@ -231,6 +231,25 @@ export const DEFAULT_BILLING_PRICE_RULES: BillingPriceRuleInput[] = [
observedAt: BILLING_CATALOG_OBSERVED_AT
}
},
...([{"resolution": "480p", "priceFen": 30}, {"resolution": "720p", "priceFen": 60}, {"resolution": "1080p", "priceFen": 120}] as const).map(({ resolution, priceFen }) => ({
id: `base-bailian-wan30-video-${resolution}`,
provider: "bailian" as const,
capability: "video.generate" as const,
reqKey: "wan3.0-video",
variantKey: `resolution=${resolution}`,
unit: "video_second" as const,
standardUnitPriceFen: priceFen,
markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER,
enabled: true,
note: `北京地域官方原价 ¥${(priceFen / 100).toFixed(2)}/秒;官网限时折扣未计入标准成本,图片输入不另收费。`,
source: {
url: "https://help.aliyun.com/zh/model-studio/model-pricing",
currency: "CNY" as const,
unitPrice: priceFen / 100,
basis: "北京地域官方原价;限时7折未计入标准成本;仅输出视频按实际秒数计费(本接入仅支持图片输入)",
observedAt: "2026-10-01"
}
})),
{
id: "base-seedance-2-0-480p",
provider: "seedance",
+73 -5
View File
@@ -101,6 +101,15 @@ export async function quoteGenerationCharge(input: BillingGenerationInput): Prom
};
}
if (input.provider === "minimax" && input.reqKey === MINIMAX_H3_MODEL) {
const inputImageCount = Math.ceil(Number(parameters.referenceImageCount) || 0);
const inputImagePriceFen = Math.max(0, inputImageCount - 5) * 20;
const amountFen = Math.ceil((quote.standardUnitPriceFen * quantity + inputImagePriceFen) * quote.markupMultiplier);
return {
...quote, amountFen, reservedAmountFen: amountFen, settlementStatus: "pending",
parameters: { ...quote.parameters, inputImageCount, inputImagePriceFen }
};
}
if (input.provider === "bailian" && input.reqKey === "wan3.0-video") {
return { ...quote, reservedAmountFen: quote.amountFen, settlementStatus: "pending" };
}
if (input.provider !== "seedance" || !isSeedanceBillingModel(input.reqKey)) {
@@ -135,7 +144,7 @@ export async function chargeGenerationJob(job: GenerationJob): Promise<Generatio
...billing,
quotaExempt: true,
status: "not_charged",
settlementStatus: job.provider === "seedance" || job.provider === "minimax" ? "pending" : billing.settlementStatus
settlementStatus: job.provider === "seedance" || job.provider === "minimax" || job.provider === "bailian" && job.reqKey === "wan3.0-video" ? "pending" : billing.settlementStatus
}
});
}
@@ -278,7 +287,7 @@ export async function settleSeedanceGenerationCharge(
export async function settleMinimaxGenerationCharge(
jobOrId: GenerationJob | string,
usage?: { outputSeconds: number; inputSeconds: number; inputImageCount: number }
usage?: { outputSeconds: number; inputSeconds: number; inputImageCount?: number }
): Promise<GenerationJob | null> {
const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId;
if (!job) return null;
@@ -293,9 +302,12 @@ export async function settleMinimaxGenerationCharge(
}
const resolution = billing.parameters?.resolution;
const inputImageCount = Number.isInteger(usage.inputImageCount) && (usage.inputImageCount ?? -1) >= 0
? usage.inputImageCount!
: minimaxQuotedInputImageCount(billing.parameters, job.requestPayload);
const actualAmountFen = calculateMinimaxH3ActualAmountFen({
resolution, outputSeconds: usage.outputSeconds, inputVideoSeconds: usage.inputSeconds,
inputImageCount: usage.inputImageCount, markupMultiplier: billing.markupMultiplier
inputImageCount, markupMultiplier: billing.markupMultiplier
});
const deltaFen = actualAmountFen - billing.amountFen;
let settlementLedgerEntryId: string | undefined;
@@ -309,7 +321,7 @@ export async function settleMinimaxGenerationCharge(
metadata: {
operation: "minimax_h3_actual_settlement", reservedAmountFen: billing.reservedAmountFen ?? billing.amountFen,
actualAmountFen, outputSeconds: usage.outputSeconds, inputVideoSeconds: usage.inputSeconds,
inputImageCount: usage.inputImageCount, resolution: String(resolution || "768P"), model: job.reqKey
inputImageCount, resolution: String(resolution || "768P"), model: job.reqKey
}
});
settlementLedgerEntryId = settlement.entry.id;
@@ -320,13 +332,58 @@ export async function settleMinimaxGenerationCharge(
...billing, amountFen: actualAmountFen, settlementStatus: "settled", settlementLedgerEntryId, settledAt,
providerUsage: {
model: job.reqKey, resolution: String(resolution || "768P"), outputSeconds: usage.outputSeconds,
inputVideoSeconds: usage.inputSeconds, inputImageCount: usage.inputImageCount,
inputVideoSeconds: usage.inputSeconds, inputImageCount,
videoPriceFenPerSecond: minimaxH3VideoPriceFenPerSecond(resolution)
}
}
});
}
export async function settleBailianWan3GenerationCharge(
jobOrId: GenerationJob | string,
outputSeconds?: number
): Promise<GenerationJob | null> {
const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId;
if (!job) return null;
const billing = job.billing;
const quotaExempt = isQuotaExemptBilling(job);
const organizationId = job.usageContext?.organizationId;
const chargeReady = billing?.status === "charged" || quotaExempt && billing?.status === "not_charged";
if (job.provider !== "bailian" || job.reqKey !== "wan3.0-video" || !billing || !chargeReady || !quotaExempt && !organizationId) return job;
if (billing.settlementStatus === "settled" || billing.settlementStatus === "estimated") return job;
if (!Number.isFinite(outputSeconds) || !outputSeconds || outputSeconds <= 0) {
return updateGenerationJob(job.id, { billing: { ...billing, settlementStatus: "estimated", settlementReason: "provider_usage_unavailable", settledAt: new Date().toISOString() } });
}
const actualAmountFen = Math.ceil(outputSeconds * billing.standardUnitPriceFen * billing.markupMultiplier);
const deltaFen = actualAmountFen - billing.amountFen;
let settlementLedgerEntryId: string | undefined;
let settledAt = new Date().toISOString();
if (deltaFen !== 0 && !quotaExempt && organizationId) {
const settlement = await postWalletEntry({
organizationId, accountId: job.usageContext?.accountId, jobId: job.id,
kind: deltaFen > 0 ? "charge" : "refund", deltaFen: -deltaFen,
idempotencyKey: `job-settlement:${job.id}`,
description: deltaFen > 0 ? `${capabilityLabel(job.capability)}实际用量补扣` : `${capabilityLabel(job.capability)}实际用量差额退回`,
metadata: {
operation: "bailian_wan30_actual_settlement", reservedAmountFen: billing.reservedAmountFen ?? billing.amountFen,
actualAmountFen, outputSeconds, resolution: String(billing.parameters?.resolution || "720p"), model: job.reqKey
}
});
settlementLedgerEntryId = settlement.entry.id;
settledAt = settlement.entry.createdAt;
}
return updateGenerationJob(job.id, {
billing: {
...billing, amountFen: actualAmountFen, settlementStatus: "settled", settlementLedgerEntryId, settledAt,
providerUsage: {
model: job.reqKey, resolution: String(billing.parameters?.resolution || "720p"), outputSeconds,
videoPriceFenPerSecond: billing.standardUnitPriceFen
}
}
});
}
function isSeedanceBillingModel(model: string) {
return model === SEEDANCE_20_MODEL || model === SEEDANCE_25_MODEL;
}
@@ -532,6 +589,17 @@ export function normalizeBillingParameters(requestPayload: Record<string, unknow
return parameters;
}
function minimaxQuotedInputImageCount(
parameters: BillingParameterSnapshot | undefined,
requestPayload: Record<string, unknown>
): number {
for (const value of [parameters?.inputImageCount, parameters?.referenceImageCount, normalizeBillingParameters(requestPayload).referenceImageCount]) {
const count = Number(value);
if (value !== undefined && Number.isInteger(count) && count >= 0) return count;
}
return 0;
}
export function effectiveBillingRuleConditions(rule: BillingPriceRule): BillingRuleConditions {
const legacyConditions = parseLegacyVariantKey(rule.variantKey);
return { ...legacyConditions, ...(rule.conditions || {}) };
+21 -11
View File
@@ -5,13 +5,13 @@ import {
recordUsageForJob,
updateGenerationJob
} from "@/lib/server/data-store";
import { chargeGenerationJob, quoteGenerationCharge, settleMinimaxGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service";
import { chargeGenerationJob, quoteGenerationCharge, settleBailianWan3GenerationCharge, settleMinimaxGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service";
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
import { importRemoteAssetAsAsset } from "@/lib/server/storage";
import { createSeedanceTask, getSeedanceConfig, querySeedanceTask, type SeedanceSettings } from "@/lib/seedance/client";
import type { BillingJobCharge, GenerationJob, UsageContext } from "@/lib/types";
import { VIDEO_DURATION_DEFAULT, clampVideoDuration, isSupportedSeedanceModel, normalizeSeedanceModel, normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution } from "@/lib/video-settings";
import { bailianResultUrls, bailianStatus, bailianTaskId, buildBailianVideoPayload, getBailianConfig, queryBailianTask, submitBailianTask } from "@/lib/bailian/client";
import { bailianOutputVideoSeconds, bailianResultUrls, bailianStatus, bailianTaskId, buildBailianVideoPayload, getBailianConfig, queryBailianTask, submitBailianTask } from "@/lib/bailian/client";
import { buildMinimaxH3Payload, createMinimaxH3Task, getMinimaxConfig, MINIMAX_H3_MODEL, queryMinimaxH3Task } from "@/lib/minimax/client";
export type VideoCreationEngine = "seedance" | "bailian" | "minimax";
@@ -121,7 +121,14 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
if (engine === "seedance" && input.model !== undefined && !isSupportedSeedanceModel(input.model)) {
throw new Error("不支持的 Seedance 视频模型。");
}
const model = normalizeSeedanceModel(input.model, config.model);
if (engine === "minimax" && input.model !== undefined && input.model !== MINIMAX_H3_MODEL) {
throw new Error("MiniMax 仅支持 H3 视频模型。");
}
const bailianModel = input.model || getBailianConfig().videoModel;
if (engine === "bailian" && !["wan2.7-i2v-2026-04-25", "wan3.0-video", getBailianConfig().videoModel].includes(bailianModel)) {
throw new Error("不支持的百炼视频模型。");
}
const model = engine === "seedance" ? normalizeSeedanceModel(input.model, config.model) : engine === "bailian" ? bailianModel : MINIMAX_H3_MODEL;
const assembled = assemblePrompt({
...input,
mode: "video",
@@ -130,9 +137,9 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
const finalPrompt = input.prompt?.trim() || assembled.prompt;
const settings: SeedanceSettings = { ...(input.settings || {}) };
if (engine === "bailian") {
settings.duration = normalizeVideoDuration(settings.duration) ?? 10;
settings.duration = settings.duration === undefined ? 10 : Number(settings.duration);
settings.resolution = String(settings.resolution || "720P").toUpperCase();
buildBailianVideoPayload({ prompt: finalPrompt, materials: assembled.materials, origin, settings });
buildBailianVideoPayload({ prompt: finalPrompt, materials: assembled.materials, origin, settings, model });
} else if (engine === "minimax") {
const payload = buildMinimaxH3Payload({ prompt: finalPrompt, materials: assembled.materials, settings, origin });
settings.duration = payload.duration;
@@ -152,7 +159,7 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
assembled,
settings,
engine,
...(engine === "seedance" ? { model } : {})
model
};
return {
ownerId,
@@ -163,7 +170,7 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
settings,
missingProviderCredentials,
provider: engine,
reqKey: engine === "bailian" ? getBailianConfig().videoModel : engine === "minimax" ? MINIMAX_H3_MODEL : model,
reqKey: model,
requestPayload
};
}
@@ -193,7 +200,7 @@ async function dispatchVideoJob(job: GenerationJob, origin: string): Promise<Gen
? assembled.materials as PromptMaterial[]
: input.materials || [];
if (job.provider === "bailian") {
const response = await submitBailianTask("video", buildBailianVideoPayload({ prompt: job.prompt || "", settings: asRecord(settings), materials, origin }));
const response = await submitBailianTask("video", buildBailianVideoPayload({ prompt: job.prompt || "", settings: asRecord(settings), materials, origin, model: job.reqKey }));
const providerTaskId = bailianTaskId(response);
if (!providerTaskId) throw new Error("视频任务响应缺少 task_id。");
return updateGenerationJob(job.id, { status: "running", providerTaskId, responsePayload: response });
@@ -231,7 +238,7 @@ export async function syncVideoJob(jobId: string, origin: string): Promise<Gener
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);
return completeRemoteVideo(job, origin, resultUrl, response, undefined, undefined, bailianOutputVideoSeconds(response));
}
if (job.provider === "minimax") {
const result = await queryMinimaxH3Task(job.providerTaskId);
@@ -270,13 +277,16 @@ async function completeRemoteVideo(
resultUrl: string,
responsePayload: Record<string, unknown>,
completionTokens?: number,
minimaxUsage?: { outputSeconds: number; inputSeconds: number; inputImageCount: number }
minimaxUsage?: { outputSeconds: number; inputSeconds: number; inputImageCount?: number },
bailianOutputSeconds?: number
) {
const settledJob = job.provider === "seedance"
? await settleSeedanceGenerationCharge(job, completionTokens) || job
: job.provider === "minimax"
? await settleMinimaxGenerationCharge(job, minimaxUsage) || job
: job;
: job.provider === "bailian"
? await settleBailianWan3GenerationCharge(job, bailianOutputSeconds) || job
: job;
const asset = await importRemoteAssetAsAsset({
ownerId: settledJob.ownerId,
url: resultUrl,
+88
View File
@@ -0,0 +1,88 @@
import { SEEDANCE_25_MODEL } from "@/lib/video-settings";
export type VideoInputEngine = "seedance" | "minimax" | "bailian";
export type VideoInputMode = "reference" | "frames";
export type VideoMaterialKind = "image" | "video" | "audio";
export const BAILIAN_27_VIDEO_MODEL = "wan2.7-i2v-2026-04-25";
export const BAILIAN_30_VIDEO_MODEL = "wan3.0-video";
export const BAILIAN_VIDEO_MODELS = [
{ id: BAILIAN_27_VIDEO_MODEL, label: "Wan 2.7 · 首尾帧" },
{ id: BAILIAN_30_VIDEO_MODEL, label: "Wan 3.0 · 多图参考" }
] as const;
export type VideoInputSelection = {
engine: VideoInputEngine;
model: string;
inputMode: VideoInputMode;
};
export function videoInputHint(selection: VideoInputSelection): string {
if (selection.engine === "seedance") {
return selection.model === SEEDANCE_25_MODEL
? "多素材参考:最多 30 张图片、10 段视频、10 段音频,总计 50 个。可在提示词中用 @图片1 等编号指定用途。"
: "多素材参考:最多 9 张图片、3 段视频、3 段音频;音频需要搭配图片或视频。可用 @图片1 等编号指定用途。";
}
if (selection.engine === "minimax") {
return selection.inputMode === "frames"
? "首帧模式:可不上传图片,或上传 1 张图片固定视频开头;有首帧时比例跟随图片。若想用多张图共同指导内容,请切换到「多图参考」。"
: "多图参考:最多 9 张图片。图片用于指导人物、服装或场景,不会被固定为视频首帧;可在提示词中引用 @图片1 等编号。";
}
if (selection.model === BAILIAN_27_VIDEO_MODEL) {
return "Wan 2.7 首尾帧:上传 1 张图片作为首帧,或按顺序上传 2 张作为首帧和尾帧。多图参考请切换到 Wan 3.0。";
}
return selection.inputMode === "frames"
? "首尾帧:上传 1 张首帧图,或按顺序上传 2 张首尾帧图。若想让更多图片共同指导内容,请切换到「多图参考」。"
: "多图参考:可只输入文字,或上传最多 10 张图片指导人物、服装和场景;参考图不会固定为首尾帧,可在提示词中引用 @图片1 等编号。";
}
export function videoMaterialError(selection: VideoInputSelection, materials: readonly { type: VideoMaterialKind }[]): string | null {
const imageCount = materials.filter((item) => item.type === "image").length;
const videoCount = materials.filter((item) => item.type === "video").length;
const audioCount = materials.filter((item) => item.type === "audio").length;
if (selection.engine === "seedance") {
const is25 = selection.model === SEEDANCE_25_MODEL;
const maxImages = is25 ? 30 : 9;
const maxVideo = is25 ? 10 : 3;
const maxAudio = is25 ? 10 : 3;
if (imageCount > maxImages || videoCount > maxVideo || audioCount > maxAudio || is25 && materials.length > 50) {
return `${is25 ? "Seedance 2.5" : "Seedance 2.0"} 最多支持 ${maxImages} 张图片、${maxVideo} 段视频、${maxAudio} 段音频${is25 ? ",总计 50 个素材" : ""};请移除超出的素材。`;
}
if (!is25 && audioCount > 0 && imageCount + videoCount === 0) {
return "Seedance 2.0 的音频不能单独生成视频;请添加至少 1 张图片或 1 段视频,或切换到 Seedance 2.5。";
}
return null;
}
if (selection.engine === "minimax") {
if (videoCount || audioCount) return "MiniMax H3 当前只接受图片素材;请移除视频和音频,或切换到 Seedance。";
if (selection.inputMode === "frames" && imageCount > 1) {
return "MiniMax H3 首帧模式最多只能上传 1 张图;请切换到「多图参考」或移除多余图片。";
}
if (selection.inputMode === "reference" && imageCount > 9) {
return "MiniMax H3 多图参考最多支持 9 张图片;请移除多余图片。";
}
return null;
}
if (videoCount || audioCount) return "百炼当前只接受图片素材;请移除视频和音频,或切换到 Seedance。";
if (selection.model === BAILIAN_27_VIDEO_MODEL || selection.inputMode === "frames") {
if (!imageCount) return "首尾帧模式需要至少 1 张图片作为首帧;请上传图片,或切换到 Wan 3.0 多图参考。";
if (imageCount > 2) return "首尾帧模式最多支持 2 张图片;请切换到 Wan 3.0 多图参考,或移除多余图片。";
return null;
}
if (imageCount > 10) return "Wan 3.0 多图参考最多支持 10 张图片;请移除多余图片。";
return null;
}
export function videoImageFileError(selection: VideoInputSelection, file: Pick<File, "name" | "size" | "type">): string | null {
if (selection.engine === "seedance" || selection.engine === "bailian" && selection.model === BAILIAN_27_VIDEO_MODEL) return null;
const format = file.name.split(".").pop()?.toLowerCase() || file.type.split("/").pop()?.toLowerCase() || "";
const isMinimax = selection.engine === "minimax";
const formats = isMinimax ? ["jpg", "jpeg", "png", "webp", "heic", "heif"] : ["jpg", "jpeg", "png", "bmp", "webp"];
const maxBytes = (isMinimax ? 30 : 20) * 1024 * 1024;
const modelLabel = isMinimax ? "MiniMax H3" : "Wan 3.0";
if (!formats.includes(format)) {
return `${modelLabel} 不支持「${file.name}」的格式;请转换为 ${isMinimax ? "JPG、PNG、WEBP、HEIC 或 HEIF" : "JPG、PNG、BMP 或 WEBP"} 后上传。`;
}
if (file.size > maxBytes) return `${modelLabel} 的单张图片不能超过 ${isMinimax ? 30 : 20} MB;请压缩「${file.name}」后重试。`;
return null;
}