若干更新迭代
This commit is contained in:
1 parent
deb9cc5bcf
commit
4ac786aafb
64 files changed
+3251
-291
No files matched your search
+35
-9
@@ -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 } : {}) }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,无法查询真实生成任务。");
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 || {}) };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user