功能增加
This commit is contained in:
1 parent
b340fc7ad7
commit
f1993eb388
69 files changed
+2243
-162
No files matched your search
@@ -0,0 +1,39 @@
|
||||
export type CanvasSize = { width: number; height: number };
|
||||
export type CanvasView = { zoom: number; x: number; y: number };
|
||||
export type CanvasPosition = { x: number; y: number };
|
||||
export const MIN_CANVAS_ZOOM = 0.25;
|
||||
export const MAX_CANVAS_ZOOM = 8;
|
||||
export const FIT_CANVAS_VIEW: CanvasView = { zoom: 1, x: 0, y: 0 };
|
||||
|
||||
export function fitCanvasImage(image: CanvasSize, viewport: CanvasSize): CanvasSize {
|
||||
if (image.width <= 0 || image.height <= 0 || viewport.width <= 0 || viewport.height <= 0) return { width: 0, height: 0 };
|
||||
const scale = Math.min(Math.max(1, viewport.width - 20) / image.width, Math.max(1, viewport.height - 20) / image.height);
|
||||
return { width: image.width * scale, height: image.height * scale };
|
||||
}
|
||||
|
||||
export function constrainCanvasView(view: CanvasView, fittedImage: CanvasSize, viewport: CanvasSize): CanvasView {
|
||||
const zoom = Math.max(MIN_CANVAS_ZOOM, Math.min(MAX_CANVAS_ZOOM, Number.isFinite(view.zoom) ? view.zoom : 1));
|
||||
const maxX = Math.max(0, (fittedImage.width * zoom - viewport.width) / 2);
|
||||
const maxY = Math.max(0, (fittedImage.height * zoom - viewport.height) / 2);
|
||||
return {
|
||||
zoom,
|
||||
x: Math.max(-maxX, Math.min(maxX, Number.isFinite(view.x) ? view.x : 0)),
|
||||
y: Math.max(-maxY, Math.min(maxY, Number.isFinite(view.y) ? view.y : 0))
|
||||
};
|
||||
}
|
||||
|
||||
// The anchor is relative to the viewport center. Preserve the image point
|
||||
// under the cursor when zooming, except where an image edge limits panning.
|
||||
export function zoomCanvasAt(view: CanvasView, requestedZoom: number, anchor: CanvasPosition, fittedImage: CanvasSize, viewport: CanvasSize): CanvasView {
|
||||
const zoom = Math.max(MIN_CANVAS_ZOOM, Math.min(MAX_CANVAS_ZOOM, requestedZoom));
|
||||
const factor = zoom / view.zoom;
|
||||
return constrainCanvasView({ zoom, x: anchor.x - (anchor.x - view.x) * factor, y: anchor.y - (anchor.y - view.y) * factor }, fittedImage, viewport);
|
||||
}
|
||||
|
||||
// Use the *transformed image bounds*, never the clipped viewport bounds.
|
||||
// Persisted annotations and provider prompts remain in the 0–1000 space.
|
||||
export function canvasAnnotationPoint(point: CanvasPosition, bounds: CanvasSize & { left: number; top: number }): CanvasPosition | null {
|
||||
if (bounds.width <= 0 || bounds.height <= 0) return null;
|
||||
const clamp = (value: number) => Math.max(0, Math.min(1000, Math.round(value)));
|
||||
return { x: clamp((point.x - bounds.left) / bounds.width * 1000), y: clamp((point.y - bounds.top) / bounds.height * 1000) };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EnabledImageCapability, GenerationStatus } from "@/lib/types";
|
||||
import { imageDimensionsForEngine } from "@/lib/image-size-presets";
|
||||
|
||||
export type ImageCreationEngine = "jimeng" | "evolink" | "bailian";
|
||||
|
||||
@@ -159,7 +160,8 @@ function assignSize(payload: Record<string, unknown>, input: Record<string, unkn
|
||||
const width = asPositiveInteger(input.width);
|
||||
const height = asPositiveInteger(input.height);
|
||||
if (!width || !height) return;
|
||||
payload.size = ratioFromDimensions(width, height) || `${width}x${height}`;
|
||||
const dimensions = imageDimensionsForEngine({ width, height }, "evolink");
|
||||
payload.size = ratioFromDimensions(dimensions.width, dimensions.height) || `${dimensions.width}x${dimensions.height}`;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export type ImageSizePreset = {
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
paper?: "a4";
|
||||
};
|
||||
|
||||
// A4 is 70:99, not 3:4. These dimensions keep reference-image generation
|
||||
// below the 2048² pixel budget. This is a generation preset, not print export.
|
||||
export const imageSizePresets: ImageSizePreset[] = [
|
||||
{ label: "1:1", width: 2048, height: 2048 },
|
||||
{ label: "4:3", width: 2304, height: 1728 },
|
||||
{ label: "3:4", width: 1728, height: 2304 },
|
||||
{ label: "16:9", width: 2560, height: 1440 },
|
||||
{ label: "9:16", width: 1440, height: 2560 },
|
||||
{ label: "A4 竖版", width: 1680, height: 2376, paper: "a4" },
|
||||
{ label: "A4 横版", width: 2376, height: 1680, paper: "a4" }
|
||||
];
|
||||
|
||||
export function imageDimensionsForEngine(size: Pick<ImageSizePreset, "width" | "height">, engine: string) {
|
||||
// EvoLink ignores resolution for explicit pixels. Stay within its existing
|
||||
// 1K budget and align to 16px; the resulting A4 ratio differs by < 0.06%.
|
||||
if (engine === "evolink") {
|
||||
if (size.width === 1680 && size.height === 2376) return { width: 848, height: 1200 };
|
||||
if (size.width === 2376 && size.height === 1680) return { width: 1200, height: 848 };
|
||||
}
|
||||
return { width: size.width, height: size.height };
|
||||
}
|
||||
|
||||
export function imageSizePresetFromDimensions(width?: number, height?: number) {
|
||||
return imageSizePresets.find((size) => {
|
||||
const evo = imageDimensionsForEngine(size, "evolink");
|
||||
return size.width === width && size.height === height || evo.width === width && evo.height === height;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { PromptMaterial } from "@/lib/prompt/assembler";
|
||||
import { toAbsoluteUrl } from "@/lib/server/runtime";
|
||||
import type { GenerationStatus } from "@/lib/types";
|
||||
|
||||
export const MINIMAX_H3_MODEL = "MiniMax-H3";
|
||||
export const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.cn";
|
||||
|
||||
export type MinimaxH3Settings = {
|
||||
ratio?: string;
|
||||
duration?: number;
|
||||
resolution?: string;
|
||||
};
|
||||
|
||||
export type MinimaxH3Usage = {
|
||||
totalSeconds: number;
|
||||
inputSeconds: number;
|
||||
outputSeconds: number;
|
||||
inputImageCount: number;
|
||||
};
|
||||
|
||||
export function getMinimaxConfig() {
|
||||
return {
|
||||
apiKey: process.env.MINIMAX_API_KEY?.trim(),
|
||||
baseUrl: (process.env.MINIMAX_BASE_URL || MINIMAX_DEFAULT_BASE_URL).replace(/\/+$/, ""),
|
||||
model: MINIMAX_H3_MODEL
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimaxH3Payload(input: {
|
||||
prompt: string;
|
||||
materials: PromptMaterial[];
|
||||
settings: MinimaxH3Settings;
|
||||
origin: string;
|
||||
}) {
|
||||
const prompt = input.prompt.trim();
|
||||
if (!prompt) throw new Error("MiniMax H3 视频提示词不能为空。");
|
||||
if (input.materials.length > 1 || input.materials.some((material) => material.type !== "image")) {
|
||||
throw new Error("MiniMax H3 第一版支持纯文字生成,或只上传 1 张图片作为首帧。");
|
||||
}
|
||||
const duration = Number(input.settings.duration ?? 5);
|
||||
if (!Number.isInteger(duration) || duration < 4 || duration > 15) {
|
||||
throw new Error("MiniMax H3 视频时长须为 4–15 秒整数。");
|
||||
}
|
||||
const resolution = String(input.settings.resolution || "768P").toUpperCase();
|
||||
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)) {
|
||||
throw new Error("MiniMax H3 文生视频须选择固定画面比例。");
|
||||
}
|
||||
return {
|
||||
model: MINIMAX_H3_MODEL,
|
||||
content: [
|
||||
{ type: "text", text: prompt },
|
||||
...input.materials.map((material) => ({
|
||||
type: "image_url",
|
||||
image_url: { url: toAbsoluteUrl(material.url, input.origin) },
|
||||
role: "first_frame"
|
||||
}))
|
||||
],
|
||||
resolution,
|
||||
duration,
|
||||
ratio,
|
||||
aigc_watermark: false
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMinimaxH3Task(payload: ReturnType<typeof buildMinimaxH3Payload>) {
|
||||
const config = requiredConfig();
|
||||
const response = await minimaxFetch(`${config.baseUrl}/v2/video_generation`, config.apiKey, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const providerTaskId = text(response.task_id);
|
||||
if (!providerTaskId) throw new Error("MiniMax H3 响应中缺少任务 ID。");
|
||||
return { providerTaskId, raw: response };
|
||||
}
|
||||
|
||||
export async function queryMinimaxH3Task(providerTaskId: string) {
|
||||
const config = requiredConfig();
|
||||
const response = await minimaxFetch(`${config.baseUrl}/v2/query/video_generation/${encodeURIComponent(providerTaskId)}`, config.apiKey);
|
||||
const task = record(response.task);
|
||||
const content = record(task.content);
|
||||
const error = record(task.error);
|
||||
return {
|
||||
status: normalizeStatus(task.status),
|
||||
resultUrl: text(content.url),
|
||||
errorMessage: minimaxTaskError(text(error.code), text(error.message)),
|
||||
usage: extractMinimaxH3Usage(task.usage),
|
||||
raw: response
|
||||
};
|
||||
}
|
||||
|
||||
export function extractMinimaxH3Usage(value: unknown): MinimaxH3Usage | undefined {
|
||||
const usage = record(value);
|
||||
const outputSeconds = nonNegativeInteger(usage.output_seconds);
|
||||
if (!outputSeconds) return undefined;
|
||||
return {
|
||||
totalSeconds: nonNegativeInteger(usage.total_seconds),
|
||||
inputSeconds: nonNegativeInteger(usage.input_seconds),
|
||||
outputSeconds,
|
||||
inputImageCount: nonNegativeInteger(usage.input_image_count)
|
||||
};
|
||||
}
|
||||
|
||||
async function minimaxFetch(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 Record<string, unknown>;
|
||||
if (!response.ok) throw new Error(minimaxHTTPError(response.status));
|
||||
return json;
|
||||
}
|
||||
|
||||
function requiredConfig() {
|
||||
const config = getMinimaxConfig();
|
||||
if (!config.apiKey) throw new Error("缺少 MINIMAX_API_KEY,请先配置 MiniMax 凭据。");
|
||||
return { ...config, apiKey: config.apiKey };
|
||||
}
|
||||
|
||||
function minimaxHTTPError(status: number) {
|
||||
if (status === 401) return "MiniMax 鉴权失败,请检查 API Key 是否正确且已开通 H3。";
|
||||
if (status === 403) return "MiniMax API Key 无 H3 调用权限,请确认已购买并开通该模型。";
|
||||
if (status === 402) return "MiniMax API 账户余额不足,请充值后重试。";
|
||||
if (status === 404) return "MiniMax H3 接口或模型不可用,请检查 Base URL 和账号模型权限。";
|
||||
if (status === 422) return "视频描述或输入素材触发内容安全审核,请调整后重试。";
|
||||
if (status === 429) return "MiniMax 请求频率过高,请稍后重试。";
|
||||
if (status >= 500) return "MiniMax 服务暂时不可用,请稍后重试。";
|
||||
return "MiniMax 请求参数不符合要求,请检查提示词、素材和生成参数。";
|
||||
}
|
||||
|
||||
function minimaxTaskError(code?: string, message?: string) {
|
||||
if (code === "1008") return "MiniMax API 账户余额不足,请充值后重试。";
|
||||
if (code === "1026") return "视频描述或输入素材触发内容安全审核,请调整后重试。";
|
||||
if (code === "1002") return "MiniMax 请求频率过高,请稍后重试。";
|
||||
return message ? "MiniMax 视频生成失败,请调整提示词或素材后重试。" : undefined;
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): GenerationStatus {
|
||||
const status = String(value || "").toLowerCase();
|
||||
if (status === "succeeded") return "succeeded";
|
||||
if (status === "failed") return "failed";
|
||||
if (status === "cancelled") return "cancelled";
|
||||
if (status === "running") return "running";
|
||||
return "queued";
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown) {
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) && number >= 0 ? number : 0;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { rootDir } from "@/lib/server/runtime";
|
||||
import { getVisualClientConfig } from "@/lib/volcengine/visual-client";
|
||||
import type { EnabledImageCapability } from "@/lib/types";
|
||||
import { getBailianConfig } from "@/lib/bailian/client";
|
||||
import { getMinimaxConfig, MINIMAX_H3_MODEL } from "@/lib/minimax/client";
|
||||
|
||||
type SettingsImageCreationEngine = ImageCreationEngine | "seedream";
|
||||
const SEEDREAM_50_PRO_MODEL = "doubao-seedream-5-0-pro-260628";
|
||||
@@ -132,6 +133,15 @@ const settingDefinitions: Array<{
|
||||
{ key: "BAILIAN_VIDEO_MODEL", label: "视频模型", defaultValue: "wan2.7-i2v-2026-04-25" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "minimax",
|
||||
title: "MiniMax API",
|
||||
description: "用于 MiniMax H3 文生视频与单首帧图生视频。",
|
||||
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" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "oss",
|
||||
title: "OSS 资产存储",
|
||||
@@ -166,6 +176,7 @@ const engineFieldDefinitions: FieldDefinition[] = [
|
||||
defaultValue: "bailian",
|
||||
options: [
|
||||
{ label: "Seedance", value: "seedance" },
|
||||
{ label: "MiniMax H3", value: "minimax" },
|
||||
{ label: "阿里云百炼 Wan 2.7", value: "bailian" }
|
||||
]
|
||||
},
|
||||
@@ -199,6 +210,7 @@ export async function getApiSettings() {
|
||||
seedance: Boolean(seedance.apiKey),
|
||||
seedream: Boolean(seedance.apiKey),
|
||||
bailian: Boolean(getBailianConfig().apiKey),
|
||||
minimax: Boolean(getMinimaxConfig().apiKey),
|
||||
auth: authConfigSummary(auth) === "configured",
|
||||
organization: platformAccountStoreConfigured()
|
||||
},
|
||||
@@ -254,9 +266,12 @@ function buildEngineAssignments(fileEnv: Map<string, string>): EngineAssignment[
|
||||
const evolink = getEvolinkImageSettings();
|
||||
const seedance = getSeedanceConfig();
|
||||
const bailian = getBailianConfig();
|
||||
const minimax = getMinimaxConfig();
|
||||
const generateEngine = currentEngineValue(engineFieldDefinitions[0], fileEnv);
|
||||
const videoEngine = currentVideoEngineValue(engineFieldDefinitions[1], fileEnv);
|
||||
const videoConnected = videoEngine === "bailian" ? Boolean(bailian.apiKey) : Boolean(seedance.apiKey);
|
||||
const videoConnected = videoEngine === "bailian"
|
||||
? Boolean(bailian.apiKey)
|
||||
: videoEngine === "minimax" ? Boolean(minimax.apiKey) : Boolean(seedance.apiKey);
|
||||
|
||||
return [
|
||||
imageEngineAssignment({
|
||||
@@ -271,10 +286,10 @@ function buildEngineAssignments(fileEnv: Map<string, string>): EngineAssignment[
|
||||
id: "video.generate",
|
||||
label: "视频生成",
|
||||
engine: videoEngine,
|
||||
engineLabel: videoEngine === "bailian" ? "阿里云百炼" : "Seedance",
|
||||
engineLabel: videoEngine === "bailian" ? "阿里云百炼" : videoEngine === "minimax" ? "MiniMax H3" : "Seedance",
|
||||
connected: videoConnected,
|
||||
connectionLabel: videoConnected ? "已连接" : "待配置",
|
||||
reqKey: videoEngine === "bailian" ? bailian.videoModel : seedance.model,
|
||||
reqKey: videoEngine === "bailian" ? bailian.videoModel : videoEngine === "minimax" ? MINIMAX_H3_MODEL : seedance.model,
|
||||
configurable: true,
|
||||
field: fieldWithValue(engineFieldDefinitions[1], videoEngine, isConfigured(engineFieldDefinitions[1].key, fileEnv))
|
||||
}
|
||||
@@ -339,7 +354,9 @@ function imageEngineConnected(engine: SettingsImageCreationEngine) {
|
||||
}
|
||||
|
||||
function currentVideoEngineValue(field: FieldDefinition, fileEnv: Map<string, string>) {
|
||||
return (process.env[field.key] ?? fileEnv.get(field.key) ?? field.defaultValue) === "bailian" ? "bailian" : "seedance";
|
||||
const value = (process.env[field.key] ?? fileEnv.get(field.key) ?? field.defaultValue)?.trim().toLowerCase();
|
||||
if (value === "bailian" || value === "minimax") return value;
|
||||
return "seedance";
|
||||
}
|
||||
|
||||
function envFilePath() {
|
||||
|
||||
@@ -16,6 +16,7 @@ const EVOLINK_IMAGE_URL = "https://evolink.ai/zh/gpt-image-2";
|
||||
const VOLC_ARK_PRICING_URL = "https://www.volcengine.com/docs/82379/1544106?lang=zh";
|
||||
const JIMENG_PRICING_URL = "https://www.volcengine.com/activity/jimeng";
|
||||
const SEEDREAM_PRICING_URL = "https://docs.volcengine.com/docs/82379/1544106?lang=zh";
|
||||
const MINIMAX_PRICING_URL = "https://platform.minimaxi.com/docs/guides/pricing-paygo#视频";
|
||||
|
||||
const SEEDREAM_50_PRO_DIMENSIONS = [
|
||||
{
|
||||
@@ -65,6 +66,8 @@ const EVOLINK_GPT_IMAGE_DIMENSIONS = [
|
||||
{ value: "3:4", label: "3:4", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true },
|
||||
{ value: "16:9", label: "16:9", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true },
|
||||
{ value: "9:16", label: "9:16", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true },
|
||||
{ value: "70:99", label: "A4 竖版", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true },
|
||||
{ value: "99:70", label: "A4 横版", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true },
|
||||
{ value: "other", label: "其他比例", match: {}, standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true, note: "同一分辨率档位下按相同比例预算估算。" }
|
||||
]
|
||||
},
|
||||
@@ -328,6 +331,44 @@ export const DEFAULT_BILLING_PRICE_RULES: BillingPriceRuleInput[] = [
|
||||
basis: "doubao-seedance-2.5,1080P,无输入视频、16:9、5 秒示例折算",
|
||||
observedAt: BILLING_CATALOG_OBSERVED_AT
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "base-minimax-h3-768p",
|
||||
provider: "minimax",
|
||||
capability: "video.generate",
|
||||
reqKey: "MiniMax-H3",
|
||||
variantKey: "resolution=768p",
|
||||
unit: "video_second",
|
||||
standardUnitPriceFen: 50,
|
||||
markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER,
|
||||
enabled: true,
|
||||
note: "MiniMax H3 768P 输出 ¥0.50/秒;首五张输入图免费。",
|
||||
source: {
|
||||
url: MINIMAX_PRICING_URL,
|
||||
currency: "CNY",
|
||||
unitPrice: 0.5,
|
||||
basis: "MiniMax H3 768P 输出视频,按成功视频秒数计费",
|
||||
observedAt: "2026-09-04"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "base-minimax-h3-2k",
|
||||
provider: "minimax",
|
||||
capability: "video.generate",
|
||||
reqKey: "MiniMax-H3",
|
||||
variantKey: "resolution=2k",
|
||||
unit: "video_second",
|
||||
standardUnitPriceFen: 80,
|
||||
markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER,
|
||||
enabled: true,
|
||||
note: "MiniMax H3 2K 输出 ¥0.80/秒;首五张输入图免费。",
|
||||
source: {
|
||||
url: MINIMAX_PRICING_URL,
|
||||
currency: "CNY",
|
||||
unitPrice: 0.8,
|
||||
basis: "MiniMax H3 2K 输出视频,按成功视频秒数计费",
|
||||
observedAt: "2026-09-04"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { getGenerationJob, updateGenerationJob } from "@/lib/server/data-store";
|
||||
import { SEEDANCE_20_MODEL, SEEDANCE_25_MODEL } from "@/lib/video-settings";
|
||||
import { calculateSeedreamAmountFen, SEEDREAM_50_PRO_MODEL } from "@/lib/server/seedream-billing";
|
||||
import { calculateMinimaxH3ActualAmountFen, MINIMAX_H3_MODEL, minimaxH3VideoPriceFenPerSecond } from "@/lib/server/minimax-billing";
|
||||
import type {
|
||||
BillingAccountConfig,
|
||||
BillingConditionValue,
|
||||
@@ -99,6 +100,9 @@ export async function quoteGenerationCharge(input: BillingGenerationInput): Prom
|
||||
}
|
||||
};
|
||||
}
|
||||
if (input.provider === "minimax" && input.reqKey === MINIMAX_H3_MODEL) {
|
||||
return { ...quote, reservedAmountFen: quote.amountFen, settlementStatus: "pending" };
|
||||
}
|
||||
if (input.provider !== "seedance" || !isSeedanceBillingModel(input.reqKey)) {
|
||||
return quote;
|
||||
}
|
||||
@@ -131,7 +135,7 @@ export async function chargeGenerationJob(job: GenerationJob): Promise<Generatio
|
||||
...billing,
|
||||
quotaExempt: true,
|
||||
status: "not_charged",
|
||||
settlementStatus: job.provider === "seedance" ? "pending" : billing.settlementStatus
|
||||
settlementStatus: job.provider === "seedance" || job.provider === "minimax" ? "pending" : billing.settlementStatus
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -156,7 +160,7 @@ export async function chargeGenerationJob(job: GenerationJob): Promise<Generatio
|
||||
...billing,
|
||||
reservedAmountFen,
|
||||
status: "charged",
|
||||
settlementStatus: job.provider === "seedance" ? "pending" : billing.settlementStatus,
|
||||
settlementStatus: job.provider === "seedance" || job.provider === "minimax" ? "pending" : billing.settlementStatus,
|
||||
ledgerEntryId: result.entry.id,
|
||||
chargedAt: result.entry.createdAt
|
||||
}
|
||||
@@ -272,6 +276,57 @@ export async function settleSeedanceGenerationCharge(
|
||||
});
|
||||
}
|
||||
|
||||
export async function settleMinimaxGenerationCharge(
|
||||
jobOrId: GenerationJob | string,
|
||||
usage?: { outputSeconds: number; inputSeconds: number; inputImageCount: 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 !== "minimax" || job.reqKey !== MINIMAX_H3_MODEL || !billing || !chargeReady || !quotaExempt && !organizationId) return job;
|
||||
if (billing.settlementStatus === "settled" || billing.settlementStatus === "estimated") return job;
|
||||
if (!usage || !Number.isFinite(usage.outputSeconds) || usage.outputSeconds <= 0) {
|
||||
return updateGenerationJob(job.id, { billing: { ...billing, settlementStatus: "estimated", settlementReason: "provider_usage_unavailable", settledAt: new Date().toISOString() } });
|
||||
}
|
||||
|
||||
const resolution = billing.parameters?.resolution;
|
||||
const actualAmountFen = calculateMinimaxH3ActualAmountFen({
|
||||
resolution, outputSeconds: usage.outputSeconds, inputVideoSeconds: usage.inputSeconds,
|
||||
inputImageCount: usage.inputImageCount, markupMultiplier: 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: "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
|
||||
}
|
||||
});
|
||||
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(resolution || "768P"), outputSeconds: usage.outputSeconds,
|
||||
inputVideoSeconds: usage.inputSeconds, inputImageCount: usage.inputImageCount,
|
||||
videoPriceFenPerSecond: minimaxH3VideoPriceFenPerSecond(resolution)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isSeedanceBillingModel(model: string) {
|
||||
return model === SEEDANCE_20_MODEL || model === SEEDANCE_25_MODEL;
|
||||
}
|
||||
@@ -418,8 +473,8 @@ export function normalizeBillingParameters(requestPayload: Record<string, unknow
|
||||
input?.size
|
||||
), normalizeSizeParameter);
|
||||
|
||||
const width = numberParameter(firstValue(providerPayload?.width, input?.width));
|
||||
const height = numberParameter(firstValue(providerPayload?.height, input?.height));
|
||||
const width = numberParameter(firstValue(providerPayload?.width, input?.width, settings?.width, requestPayload.width));
|
||||
const height = numberParameter(firstValue(providerPayload?.height, input?.height, settings?.height, requestPayload.height));
|
||||
if (parameters.size === undefined && width !== undefined && height !== undefined) {
|
||||
parameters.size = `${width}*${height}`;
|
||||
}
|
||||
@@ -624,7 +679,7 @@ function normalizeAspectRatioParameter(value: unknown, width?: number, height?:
|
||||
if (normalized) return normalized;
|
||||
if (width === undefined || height === undefined || height <= 0) return undefined;
|
||||
const ratio = width / height;
|
||||
const common = [[1, 1], [4, 3], [3, 2], [16, 9], [9, 16], [21, 9], [9, 21], [2, 3], [3, 4]] as const;
|
||||
const common = [[1, 1], [4, 3], [3, 2], [16, 9], [9, 16], [21, 9], [9, 21], [2, 3], [3, 4], [70, 99], [99, 70]] as const;
|
||||
const match = common.find(([numerator, denominator]) => Math.abs(ratio - numerator / denominator) < 0.02);
|
||||
return match ? `${match[0]}:${match[1]}` : ratio.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
|
||||
}
|
||||
|
||||
+25
-12
@@ -759,18 +759,7 @@ function billingJobChargeFromValue(value: unknown): GenerationJob["billing"] {
|
||||
settlementLedgerEntryId: optionalString(value.settlementLedgerEntryId),
|
||||
settledAt: optionalString(value.settledAt),
|
||||
settlementReason: optionalString(value.settlementReason),
|
||||
providerUsage: isRecord(value.providerUsage)
|
||||
&& Number.isFinite(Number(value.providerUsage.completionTokens))
|
||||
&& typeof value.providerUsage.resolution === "string"
|
||||
&& typeof value.providerUsage.inputVideo === "boolean"
|
||||
&& Number.isFinite(Number(value.providerUsage.tokenPriceFenPerMillion))
|
||||
? {
|
||||
completionTokens: Number(value.providerUsage.completionTokens),
|
||||
resolution: value.providerUsage.resolution,
|
||||
inputVideo: value.providerUsage.inputVideo,
|
||||
tokenPriceFenPerMillion: Number(value.providerUsage.tokenPriceFenPerMillion)
|
||||
}
|
||||
: undefined,
|
||||
providerUsage: billingProviderUsageFromValue(value.providerUsage),
|
||||
ledgerEntryId: optionalString(value.ledgerEntryId),
|
||||
refundLedgerEntryId: optionalString(value.refundLedgerEntryId),
|
||||
chargedAt: optionalString(value.chargedAt),
|
||||
@@ -779,6 +768,30 @@ function billingJobChargeFromValue(value: unknown): GenerationJob["billing"] {
|
||||
};
|
||||
}
|
||||
|
||||
function billingProviderUsageFromValue(value: unknown): NonNullable<GenerationJob["billing"]>["providerUsage"] {
|
||||
if (!isRecord(value) || typeof value.resolution !== "string") return undefined;
|
||||
const completionTokens = optionalNumber(value.completionTokens);
|
||||
const tokenPriceFenPerMillion = optionalNumber(value.tokenPriceFenPerMillion);
|
||||
const outputSeconds = optionalNumber(value.outputSeconds);
|
||||
const inputVideoSeconds = optionalNumber(value.inputVideoSeconds);
|
||||
const inputImageCount = optionalNumber(value.inputImageCount);
|
||||
const videoPriceFenPerSecond = optionalNumber(value.videoPriceFenPerSecond);
|
||||
const inputVideo = typeof value.inputVideo === "boolean" ? value.inputVideo : undefined;
|
||||
const model = optionalString(value.model);
|
||||
if (completionTokens === undefined && outputSeconds === undefined) return undefined;
|
||||
return {
|
||||
resolution: value.resolution,
|
||||
model,
|
||||
completionTokens,
|
||||
inputVideo,
|
||||
tokenPriceFenPerMillion,
|
||||
outputSeconds,
|
||||
inputVideoSeconds,
|
||||
inputImageCount,
|
||||
videoPriceFenPerSecond
|
||||
};
|
||||
}
|
||||
|
||||
function imageTemplateToRow(template: Partial<ImageTemplate>) {
|
||||
const row: Record<string, unknown> = {};
|
||||
if (template.id !== undefined) row.id = template.id;
|
||||
|
||||
@@ -65,6 +65,17 @@ export async function checkDatabaseReadiness(): Promise<void> {
|
||||
WHERE to_regclass('public.' || table_name) IS NULL
|
||||
OR NOT has_table_privilege(current_user, 'public.' || table_name, privilege_name)
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint AS constraint_record
|
||||
JOIN pg_class AS constrained_table ON constrained_table.oid = constraint_record.conrelid
|
||||
JOIN pg_namespace AS constrained_schema ON constrained_schema.oid = constrained_table.relnamespace
|
||||
WHERE constrained_schema.nspname = 'public'
|
||||
AND constrained_table.relname = 'billing_price_rules'
|
||||
AND constraint_record.conname = 'billing_price_rules_provider_check'
|
||||
AND position('seedream' IN pg_get_constraintdef(constraint_record.oid)) > 0
|
||||
AND position('minimax' IN pg_get_constraintdef(constraint_record.oid)) > 0
|
||||
)
|
||||
AND has_function_privilege(
|
||||
current_user,
|
||||
'public.claim_generation_jobs(text,integer,integer)',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export const MINIMAX_H3_MODEL = "MiniMax-H3";
|
||||
|
||||
export function minimaxH3VideoPriceFenPerSecond(resolution: unknown) {
|
||||
return String(resolution || "").trim().toUpperCase() === "2K" ? 80 : 50;
|
||||
}
|
||||
|
||||
export function calculateMinimaxH3ActualAmountFen(input: {
|
||||
resolution?: unknown;
|
||||
outputSeconds: number;
|
||||
inputVideoSeconds?: number;
|
||||
inputImageCount?: number;
|
||||
markupMultiplier: number;
|
||||
}) {
|
||||
const outputSeconds = integer(input.outputSeconds);
|
||||
const inputVideoSeconds = nonNegativeInteger(input.inputVideoSeconds);
|
||||
const inputImageCount = nonNegativeInteger(input.inputImageCount);
|
||||
if (outputSeconds <= 0) throw new Error("MiniMax H3 实际输出时长必须大于 0。");
|
||||
if (!Number.isFinite(input.markupMultiplier) || input.markupMultiplier < 1) throw new Error("MiniMax H3 计费倍率不能低于 1.00。");
|
||||
const standardAmountFen = (outputSeconds + inputVideoSeconds) * minimaxH3VideoPriceFenPerSecond(input.resolution)
|
||||
+ Math.max(0, inputImageCount - 5) * 20;
|
||||
return Math.ceil(standardAmountFen * input.markupMultiplier);
|
||||
}
|
||||
|
||||
function integer(value: unknown) {
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) ? number : 0;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown) {
|
||||
return Math.max(0, integer(value));
|
||||
}
|
||||
@@ -5,15 +5,16 @@ import {
|
||||
recordUsageForJob,
|
||||
updateGenerationJob
|
||||
} from "@/lib/server/data-store";
|
||||
import { chargeGenerationJob, quoteGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service";
|
||||
import { chargeGenerationJob, quoteGenerationCharge, 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 { buildMinimaxH3Payload, createMinimaxH3Task, getMinimaxConfig, MINIMAX_H3_MODEL, queryMinimaxH3Task } from "@/lib/minimax/client";
|
||||
|
||||
export type VideoCreationEngine = "seedance" | "bailian";
|
||||
export type VideoCreationEngine = "seedance" | "bailian" | "minimax";
|
||||
|
||||
export type SubmitVideoJobInput = PromptAssemblyInput & {
|
||||
model?: string;
|
||||
@@ -40,7 +41,7 @@ type PreparedVideoGeneration = {
|
||||
finalPrompt: string;
|
||||
settings: SeedanceSettings;
|
||||
missingProviderCredentials: boolean;
|
||||
provider: "bailian" | "seedance";
|
||||
provider: "bailian" | "seedance" | "minimax";
|
||||
reqKey: string;
|
||||
requestPayload: Record<string, unknown>;
|
||||
};
|
||||
@@ -110,8 +111,10 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string)
|
||||
|
||||
function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): PreparedVideoGeneration {
|
||||
const ownerId = input.ownerId || DEFAULT_OWNER_ID;
|
||||
const configuredEngine = process.env.VIDEO_GENERATE_ENGINE === "seedance" ? "seedance" : "bailian";
|
||||
const engine: VideoCreationEngine = input.engine === "seedance" || input.engine === "bailian"
|
||||
const configuredEngine: VideoCreationEngine = process.env.VIDEO_GENERATE_ENGINE === "seedance"
|
||||
? "seedance"
|
||||
: process.env.VIDEO_GENERATE_ENGINE === "minimax" ? "minimax" : "bailian";
|
||||
const engine: VideoCreationEngine = input.engine === "seedance" || input.engine === "bailian" || input.engine === "minimax"
|
||||
? input.engine
|
||||
: configuredEngine;
|
||||
const config = getSeedanceConfig();
|
||||
@@ -130,13 +133,20 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
|
||||
settings.duration = normalizeVideoDuration(settings.duration) ?? 10;
|
||||
settings.resolution = String(settings.resolution || "720P").toUpperCase();
|
||||
buildBailianVideoPayload({ prompt: finalPrompt, materials: assembled.materials, origin, settings });
|
||||
} else if (engine === "minimax") {
|
||||
const payload = buildMinimaxH3Payload({ prompt: finalPrompt, materials: assembled.materials, settings, origin });
|
||||
settings.duration = payload.duration;
|
||||
settings.resolution = payload.resolution;
|
||||
settings.ratio = payload.ratio;
|
||||
} else {
|
||||
settings.ratio = normalizeVideoRatio(settings.ratio, config.ratio);
|
||||
settings.duration = normalizeVideoDuration(settings.duration, model)
|
||||
?? clampVideoDuration(config.duration, VIDEO_DURATION_DEFAULT, { model });
|
||||
settings.resolution = normalizeVideoResolution(settings.resolution, model, config.resolution);
|
||||
}
|
||||
const missingProviderCredentials = engine === "bailian" ? !getBailianConfig().apiKey : !config.apiKey;
|
||||
const missingProviderCredentials = engine === "bailian"
|
||||
? !getBailianConfig().apiKey
|
||||
: engine === "minimax" ? !getMinimaxConfig().apiKey : !config.apiKey;
|
||||
const requestPayload = {
|
||||
input,
|
||||
assembled,
|
||||
@@ -153,12 +163,13 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
|
||||
settings,
|
||||
missingProviderCredentials,
|
||||
provider: engine,
|
||||
reqKey: engine === "bailian" ? getBailianConfig().videoModel : model,
|
||||
reqKey: engine === "bailian" ? getBailianConfig().videoModel : engine === "minimax" ? MINIMAX_H3_MODEL : model,
|
||||
requestPayload
|
||||
};
|
||||
}
|
||||
|
||||
function missingProviderMessage(engine: VideoCreationEngine) {
|
||||
if (engine === "minimax") return "缺少 MINIMAX_API_KEY,请先配置 MiniMax 凭据。";
|
||||
return engine === "bailian"
|
||||
? "缺少 BAILIAN_API_KEY 或 DASHSCOPE_API_KEY,请先配置阿里云百炼凭据。"
|
||||
: "缺少 SEEDANCE_API_KEY,请先配置 Seedance 凭据。";
|
||||
@@ -187,6 +198,11 @@ async function dispatchVideoJob(job: GenerationJob, origin: string): Promise<Gen
|
||||
if (!providerTaskId) throw new Error("视频任务响应缺少 task_id。");
|
||||
return updateGenerationJob(job.id, { status: "running", providerTaskId, responsePayload: response });
|
||||
}
|
||||
if (job.provider === "minimax") {
|
||||
const payload = buildMinimaxH3Payload({ prompt: job.prompt || "", settings, materials, origin });
|
||||
const response = await createMinimaxH3Task(payload);
|
||||
return updateGenerationJob(job.id, { status: "running", providerTaskId: response.providerTaskId, responsePayload: response.raw });
|
||||
}
|
||||
const response = await createSeedanceTask({ model: job.reqKey, prompt: job.prompt || "", settings, materials, origin });
|
||||
return updateGenerationJob(job.id, { status: "running", providerTaskId: response.providerTaskId, responsePayload: response.raw });
|
||||
} catch (error) {
|
||||
@@ -217,6 +233,17 @@ export async function syncVideoJob(jobId: string, origin: string): Promise<Gener
|
||||
}
|
||||
return completeRemoteVideo(job, origin, resultUrl, response);
|
||||
}
|
||||
if (job.provider === "minimax") {
|
||||
const result = await queryMinimaxH3Task(job.providerTaskId);
|
||||
if (result.status !== "succeeded" || !result.resultUrl) {
|
||||
return updateGenerationJob(job.id, {
|
||||
status: result.status,
|
||||
responsePayload: result.raw,
|
||||
error: result.errorMessage ? { message: result.errorMessage, retryable: false } : undefined
|
||||
});
|
||||
}
|
||||
return completeRemoteVideo(job, origin, result.resultUrl, result.raw, undefined, result.usage);
|
||||
}
|
||||
const result = await querySeedanceTask(job.providerTaskId);
|
||||
if (result.status !== "succeeded" || !result.resultUrl) {
|
||||
return updateGenerationJob(job.id, {
|
||||
@@ -242,11 +269,14 @@ async function completeRemoteVideo(
|
||||
origin: string,
|
||||
resultUrl: string,
|
||||
responsePayload: Record<string, unknown>,
|
||||
completionTokens?: number
|
||||
completionTokens?: number,
|
||||
minimaxUsage?: { outputSeconds: number; inputSeconds: number; inputImageCount: number }
|
||||
) {
|
||||
const settledJob = job.provider === "seedance"
|
||||
? await settleSeedanceGenerationCharge(job, completionTokens) || job
|
||||
: job;
|
||||
: job.provider === "minimax"
|
||||
? await settleMinimaxGenerationCharge(job, minimaxUsage) || job
|
||||
: job;
|
||||
const asset = await importRemoteAssetAsAsset({
|
||||
ownerId: settledJob.ownerId,
|
||||
url: resultUrl,
|
||||
|
||||
+8
-3
@@ -13,6 +13,7 @@ export type GenerationProvider =
|
||||
| "seedance"
|
||||
| "seedream"
|
||||
| "bailian"
|
||||
| "minimax"
|
||||
| "mock";
|
||||
|
||||
export type BillingCurrency = "CNY";
|
||||
@@ -118,11 +119,15 @@ export type BillingJobCharge = BillingQuote & {
|
||||
settledAt?: string;
|
||||
settlementReason?: string;
|
||||
providerUsage?: {
|
||||
completionTokens: number;
|
||||
completionTokens?: number;
|
||||
model?: string;
|
||||
resolution: string;
|
||||
inputVideo: boolean;
|
||||
tokenPriceFenPerMillion: number;
|
||||
inputVideo?: boolean;
|
||||
tokenPriceFenPerMillion?: number;
|
||||
outputSeconds?: number;
|
||||
inputVideoSeconds?: number;
|
||||
inputImageCount?: number;
|
||||
videoPriceFenPerSecond?: number;
|
||||
};
|
||||
ledgerEntryId?: string;
|
||||
refundLedgerEntryId?: string;
|
||||
|
||||
@@ -118,6 +118,7 @@ export function providerLabel(provider?: GenerationProvider): string {
|
||||
if (provider === "seedance") return "Seedance";
|
||||
if (provider === "seedream") return "Seedream 5.0 Pro";
|
||||
if (provider === "bailian") return "阿里云百炼";
|
||||
if (provider === "minimax") return "MiniMax H3";
|
||||
if (provider === "mock") return "历史任务";
|
||||
return "未知服务商";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user