增加即梦图片5.0和视频2.5的能力
This commit is contained in:
1 parent
542246e4ce
commit
88a4268d40
55 files changed
+1412
-200
No files matched your search
@@ -10,6 +10,9 @@ import { getVisualClientConfig } from "@/lib/volcengine/visual-client";
|
||||
import type { EnabledImageCapability } from "@/lib/types";
|
||||
import { getBailianConfig } from "@/lib/bailian/client";
|
||||
|
||||
type SettingsImageCreationEngine = ImageCreationEngine | "seedream";
|
||||
const SEEDREAM_50_PRO_MODEL = "doubao-seedream-5-0-pro-260628";
|
||||
|
||||
export type SettingsField = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -112,8 +115,8 @@ const settingDefinitions: Array<{
|
||||
},
|
||||
{
|
||||
id: "seedance",
|
||||
title: "Seedance 视频 API",
|
||||
description: "只需填写火山方舟 API Key;Base URL、模型和默认参数使用系统默认值。",
|
||||
title: "火山方舟 API",
|
||||
description: "Seedance 视频与 Seedream 5.0 Pro 图片共用此 API Key;其余参数使用系统默认值。",
|
||||
fields: [
|
||||
{ key: "SEEDANCE_API_KEY", label: "方舟 API Key", secret: true, type: "password" }
|
||||
]
|
||||
@@ -151,6 +154,7 @@ const engineFieldDefinitions: FieldDefinition[] = [
|
||||
defaultValue: "jimeng",
|
||||
options: [
|
||||
{ label: "即梦 / 火山视觉", value: "jimeng" },
|
||||
{ label: "Seedream 5.0 Pro / 火山方舟", value: "seedream" },
|
||||
{ label: "EvoLink GPT Image 2", value: "evolink" },
|
||||
{ label: "阿里云百炼 Wan 2.7", value: "bailian" }
|
||||
]
|
||||
@@ -193,6 +197,7 @@ export async function getApiSettings() {
|
||||
visual: Boolean(getVisualClientConfig()),
|
||||
evolink: Boolean(getEvolinkImageConfig()),
|
||||
seedance: Boolean(seedance.apiKey),
|
||||
seedream: Boolean(seedance.apiKey),
|
||||
bailian: Boolean(getBailianConfig().apiKey),
|
||||
auth: authConfigSummary(auth) === "configured",
|
||||
organization: platformAccountStoreConfigured()
|
||||
@@ -258,7 +263,7 @@ function buildEngineAssignments(fileEnv: Map<string, string>): EngineAssignment[
|
||||
capability: "image.generate",
|
||||
label: "图片生成",
|
||||
engine: generateEngine,
|
||||
reqKey: generateEngine === "bailian" ? bailian.imageModel : generateEngine === "evolink" ? evolink.model : capabilities["image.generate"].reqKey,
|
||||
reqKey: generateEngine === "bailian" ? bailian.imageModel : generateEngine === "evolink" ? evolink.model : generateEngine === "seedream" ? SEEDREAM_50_PRO_MODEL : capabilities["image.generate"].reqKey,
|
||||
field: engineFieldDefinitions[0],
|
||||
fileEnv
|
||||
}),
|
||||
@@ -279,7 +284,7 @@ function buildEngineAssignments(fileEnv: Map<string, string>): EngineAssignment[
|
||||
function imageEngineAssignment(input: {
|
||||
capability: EnabledImageCapability;
|
||||
label: string;
|
||||
engine: ImageCreationEngine;
|
||||
engine: SettingsImageCreationEngine;
|
||||
reqKey: string;
|
||||
field: FieldDefinition;
|
||||
fileEnv: Map<string, string>;
|
||||
@@ -298,7 +303,7 @@ function imageEngineAssignment(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function currentEngineValue(field: FieldDefinition, fileEnv: Map<string, string>): ImageCreationEngine {
|
||||
function currentEngineValue(field: FieldDefinition, fileEnv: Map<string, string>): SettingsImageCreationEngine {
|
||||
const explicit = process.env[field.key] ?? fileEnv.get(field.key);
|
||||
if (explicit) return normalizeImageEngine(explicit);
|
||||
return normalizeImageEngine(process.env.IMAGE_CREATION_ENGINE ?? fileEnv.get("IMAGE_CREATION_ENGINE") ?? process.env.IMAGE_PROVIDER ?? fileEnv.get("IMAGE_PROVIDER") ?? field.defaultValue);
|
||||
@@ -316,16 +321,18 @@ function isConfigured(key: string, fileEnv: Map<string, string>) {
|
||||
return Boolean(process.env[key] ?? fileEnv.get(key));
|
||||
}
|
||||
|
||||
function normalizeImageEngine(value: string | undefined): ImageCreationEngine {
|
||||
function normalizeImageEngine(value: string | undefined): SettingsImageCreationEngine {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "seedream") return "seedream";
|
||||
return normalized === "evolink" || normalized === "bailian" ? normalized : "jimeng";
|
||||
}
|
||||
|
||||
function imageEngineLabel(engine: ImageCreationEngine) {
|
||||
return engine === "evolink" ? "EvoLink" : engine === "bailian" ? "阿里云百炼" : "即梦";
|
||||
function imageEngineLabel(engine: SettingsImageCreationEngine) {
|
||||
return engine === "evolink" ? "EvoLink" : engine === "bailian" ? "阿里云百炼" : engine === "seedream" ? "Seedream 5.0 Pro" : "即梦";
|
||||
}
|
||||
|
||||
function imageEngineConnected(engine: ImageCreationEngine) {
|
||||
function imageEngineConnected(engine: SettingsImageCreationEngine) {
|
||||
if (engine === "seedream") return Boolean(getSeedanceConfig().apiKey);
|
||||
if (engine === "evolink") return Boolean(getEvolinkImageConfig());
|
||||
if (engine === "bailian") return Boolean(getBailianConfig().apiKey);
|
||||
return Boolean(getVisualClientConfig());
|
||||
|
||||
@@ -8,13 +8,28 @@ import type { BillingPriceRule } from "@/lib/types";
|
||||
|
||||
export const BILLING_USD_CNY_RATE = 7.2;
|
||||
export const DEFAULT_BILLING_MARKUP_MULTIPLIER = 1.2;
|
||||
export const BILLING_CATALOG_OBSERVED_AT = "2026-08-11";
|
||||
export const BILLING_CATALOG_OBSERVED_AT = "2026-08-21";
|
||||
|
||||
const BAILIAN_IMAGE_URL = "https://help.aliyun.com/zh/model-studio/wan2-7-image-pro";
|
||||
const BAILIAN_VIDEO_URL = "https://help.aliyun.com/zh/model-studio/wan2-7-i2v";
|
||||
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 SEEDREAM_50_PRO_DIMENSIONS = [
|
||||
{
|
||||
key: "size",
|
||||
label: "分辨率",
|
||||
baselineValue: "1.5K",
|
||||
defaultValue: "1.5K",
|
||||
tiers: [
|
||||
{ value: "1K", label: "1K", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true },
|
||||
{ value: "1.5K", label: "1.5K", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true },
|
||||
{ value: "2K", label: "2K", standardFactor: 2, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }
|
||||
]
|
||||
}
|
||||
] satisfies NonNullable<BillingPriceRuleInput["parameterDimensions"]>;
|
||||
|
||||
const EVOLINK_GPT_IMAGE_DIMENSIONS = [
|
||||
{
|
||||
@@ -123,6 +138,25 @@ export const DEFAULT_BILLING_PRICE_RULES: BillingPriceRuleInput[] = [
|
||||
observedAt: BILLING_CATALOG_OBSERVED_AT
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "base-seedream-5-0-pro",
|
||||
provider: "seedream",
|
||||
capability: "image.generate",
|
||||
reqKey: "doubao-seedream-5-0-pro-260628",
|
||||
unit: "image",
|
||||
standardUnitPriceFen: 30,
|
||||
markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER,
|
||||
enabled: true,
|
||||
note: "基础生图:1K/1.5K ¥0.30,2K ¥0.60;首张输入图免费,额外输入图 ¥0.02/张。",
|
||||
parameterDimensions: SEEDREAM_50_PRO_DIMENSIONS,
|
||||
source: {
|
||||
url: SEEDREAM_PRICING_URL,
|
||||
currency: "CNY",
|
||||
unitPrice: 0.3,
|
||||
basis: "Seedream 5.0 Pro 单图基础生图;2K 按 ¥0.60/张,额外参考图按 ¥0.02/张",
|
||||
observedAt: BILLING_CATALOG_OBSERVED_AT
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "base-bailian-wan27-i2v-720p",
|
||||
provider: "bailian",
|
||||
@@ -236,6 +270,63 @@ export const DEFAULT_BILLING_PRICE_RULES: BillingPriceRuleInput[] = [
|
||||
basis: "doubao-seedance-2.0,4K,无输入视频、16:9、5 秒示例折算;实际 token 计费可能随输入变化",
|
||||
observedAt: BILLING_CATALOG_OBSERVED_AT
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "base-seedance-2-5-480p",
|
||||
provider: "seedance",
|
||||
capability: "video.generate",
|
||||
reqKey: "doubao-seedance-2-5-260628",
|
||||
variantKey: "resolution=480p",
|
||||
unit: "video_second",
|
||||
standardUnitPriceFen: 67,
|
||||
markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER,
|
||||
enabled: true,
|
||||
note: "官方 5 秒、16:9、无输入视频样例 ¥3.36,实际按方舟 token 用量结算。",
|
||||
source: {
|
||||
url: `${VOLC_ARK_PRICING_URL}#sd25_price`,
|
||||
currency: "CNY",
|
||||
unitPrice: 0.67,
|
||||
basis: "doubao-seedance-2.5,480P,无输入视频、16:9、5 秒示例折算",
|
||||
observedAt: BILLING_CATALOG_OBSERVED_AT
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "base-seedance-2-5-720p",
|
||||
provider: "seedance",
|
||||
capability: "video.generate",
|
||||
reqKey: "doubao-seedance-2-5-260628",
|
||||
variantKey: "resolution=720p",
|
||||
unit: "video_second",
|
||||
standardUnitPriceFen: 151,
|
||||
markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER,
|
||||
enabled: true,
|
||||
note: "官方 5 秒、16:9、无输入视频样例 ¥7.56,实际按方舟 token 用量结算。",
|
||||
source: {
|
||||
url: `${VOLC_ARK_PRICING_URL}#sd25_price`,
|
||||
currency: "CNY",
|
||||
unitPrice: 1.51,
|
||||
basis: "doubao-seedance-2.5,720P,无输入视频、16:9、5 秒示例折算",
|
||||
observedAt: BILLING_CATALOG_OBSERVED_AT
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "base-seedance-2-5-1080p",
|
||||
provider: "seedance",
|
||||
capability: "video.generate",
|
||||
reqKey: "doubao-seedance-2-5-260628",
|
||||
variantKey: "resolution=1080p",
|
||||
unit: "video_second",
|
||||
standardUnitPriceFen: 374,
|
||||
markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER,
|
||||
enabled: true,
|
||||
note: "官方 5 秒、16:9、无输入视频样例 ¥18.71,实际按方舟 token 用量结算。",
|
||||
source: {
|
||||
url: `${VOLC_ARK_PRICING_URL}#sd25_price`,
|
||||
currency: "CNY",
|
||||
unitPrice: 3.74,
|
||||
basis: "doubao-seedance-2.5,1080P,无输入视频、16:9、5 秒示例折算",
|
||||
observedAt: BILLING_CATALOG_OBSERVED_AT
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
seedanceTokenPriceFenPerMillion
|
||||
} from "@/lib/server/seedance-billing";
|
||||
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 type {
|
||||
BillingAccountConfig,
|
||||
BillingConditionValue,
|
||||
@@ -81,17 +83,34 @@ export async function quoteGenerationCharge(input: BillingGenerationInput): Prom
|
||||
status: "pending",
|
||||
quotaExempt: quotaExempt || undefined
|
||||
};
|
||||
if (input.provider !== "seedance" || input.reqKey !== "doubao-seedance-2-0-260128") {
|
||||
if (input.provider === "seedream" && input.reqKey === SEEDREAM_50_PRO_MODEL) {
|
||||
const referenceImageCount = Math.ceil(Number(parameters.referenceImageCount) || 0);
|
||||
return {
|
||||
...quote,
|
||||
amountFen: calculateSeedreamAmountFen({
|
||||
outputPriceFen: quote.standardUnitPriceFen,
|
||||
referenceImageCount,
|
||||
markupMultiplier: quote.markupMultiplier
|
||||
}),
|
||||
parameters: {
|
||||
...quote.parameters,
|
||||
referenceImageCount,
|
||||
inputReferencePriceFen: Math.max(0, referenceImageCount - 1) * 2
|
||||
}
|
||||
};
|
||||
}
|
||||
if (input.provider !== "seedance" || !isSeedanceBillingModel(input.reqKey)) {
|
||||
return quote;
|
||||
}
|
||||
|
||||
const inputVideo = requestHasInputVideo(input.requestPayload);
|
||||
const estimatedAmountFen = estimateSeedanceAmountFen({
|
||||
model: input.reqKey,
|
||||
resolution: parameters.resolution,
|
||||
aspectRatio: parameters.aspectRatio,
|
||||
outputDurationSeconds: quantity,
|
||||
inputVideo,
|
||||
inputVideoDurationSeconds: requestInputVideoDurationSeconds(input.requestPayload),
|
||||
inputVideoDurationSeconds: requestInputVideoDurationSeconds(input.requestPayload, input.reqKey),
|
||||
markupMultiplier: quote.markupMultiplier
|
||||
});
|
||||
const reservedAmountFen = Math.max(quote.amountFen, estimatedAmountFen);
|
||||
@@ -202,6 +221,7 @@ export async function settleSeedanceGenerationCharge(
|
||||
const inputVideo = requestHasInputVideo(job.requestPayload);
|
||||
const resolution = billing.parameters?.resolution;
|
||||
const actualAmountFen = calculateSeedanceActualAmountFen({
|
||||
model: job.reqKey,
|
||||
resolution,
|
||||
inputVideo,
|
||||
completionTokens: Number(completionTokens),
|
||||
@@ -226,7 +246,8 @@ export async function settleSeedanceGenerationCharge(
|
||||
actualAmountFen,
|
||||
completionTokens: Math.floor(Number(completionTokens)),
|
||||
inputVideo,
|
||||
resolution: String(resolution || "720p")
|
||||
resolution: String(resolution || "720p"),
|
||||
model: job.reqKey
|
||||
}
|
||||
});
|
||||
settlementLedgerEntryId = settlement.entry.id;
|
||||
@@ -244,12 +265,17 @@ export async function settleSeedanceGenerationCharge(
|
||||
completionTokens: Math.floor(Number(completionTokens)),
|
||||
resolution: String(resolution || "720p"),
|
||||
inputVideo,
|
||||
tokenPriceFenPerMillion: seedanceTokenPriceFenPerMillion(resolution, inputVideo)
|
||||
model: job.reqKey,
|
||||
tokenPriceFenPerMillion: seedanceTokenPriceFenPerMillion(job.reqKey, resolution, inputVideo)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isSeedanceBillingModel(model: string) {
|
||||
return model === SEEDANCE_20_MODEL || model === SEEDANCE_25_MODEL;
|
||||
}
|
||||
|
||||
export async function getOrganizationBillingSnapshot(organizationId: string) {
|
||||
const wallet = await getOrganizationWallet(organizationId);
|
||||
return {
|
||||
@@ -385,6 +411,7 @@ export function normalizeBillingParameters(requestPayload: Record<string, unknow
|
||||
input?.resolution
|
||||
), normalizeTextParameter);
|
||||
setParameter(parameters, "size", firstValue(
|
||||
settings?.size,
|
||||
providerPayload?.size,
|
||||
providerParameters?.size,
|
||||
inputSettings?.size,
|
||||
@@ -423,6 +450,7 @@ export function normalizeBillingParameters(requestPayload: Record<string, unknow
|
||||
), numberParameter);
|
||||
|
||||
const imageCount = numberParameter(firstValue(
|
||||
settings?.imageCount,
|
||||
providerPayload?.n,
|
||||
providerParameters?.n,
|
||||
input?.n,
|
||||
@@ -526,11 +554,14 @@ function countReferenceImages(input: {
|
||||
assembled?: Record<string, unknown>;
|
||||
}): number {
|
||||
const urls = [
|
||||
...stringArray(input.requestPayload.imageUrls),
|
||||
...stringArray(input.requestPayload.inputUrls),
|
||||
...stringArray(input.input?.imageUrls),
|
||||
...stringArray(input.providerPayload?.image_urls),
|
||||
...stringArray(input.providerPayload?.imageUrls)
|
||||
];
|
||||
const materials = [
|
||||
...(Array.isArray(input.requestPayload.materials) ? input.requestPayload.materials : []),
|
||||
...(Array.isArray(input.input?.materials) ? input.input.materials : []),
|
||||
...(Array.isArray(input.assembled?.materials) ? input.assembled.materials : [])
|
||||
];
|
||||
|
||||
@@ -803,12 +803,15 @@ function imageTemplateFromRow(row: Record<string, unknown>): ImageTemplate {
|
||||
prompt: String(row.prompt || ""),
|
||||
previewImageUrl: optionalString(row.preview_image_url),
|
||||
settings: isRecord(row.settings) ? {
|
||||
engine: row.settings.engine === "jimeng" || row.settings.engine === "evolink" || row.settings.engine === "bailian" ? row.settings.engine : undefined,
|
||||
engine: row.settings.engine === "jimeng" || row.settings.engine === "evolink" || row.settings.engine === "bailian" || row.settings.engine === "seedream" ? row.settings.engine : undefined,
|
||||
width: optionalNumber(row.settings.width),
|
||||
height: optionalNumber(row.settings.height),
|
||||
forceSingle: typeof row.settings.forceSingle === "boolean" ? row.settings.forceSingle : undefined,
|
||||
scale: optionalNumber(row.settings.scale),
|
||||
quality: row.settings.quality === "low" || row.settings.quality === "medium" || row.settings.quality === "high" ? row.settings.quality : undefined
|
||||
quality: row.settings.quality === "low" || row.settings.quality === "medium" || row.settings.quality === "high" ? row.settings.quality : undefined,
|
||||
size: row.settings.size === "1K" || row.settings.size === "1.5K" || row.settings.size === "2K" ? row.settings.size : undefined,
|
||||
outputFormat: row.settings.outputFormat === "png" || row.settings.outputFormat === "jpeg" ? row.settings.outputFormat : undefined,
|
||||
optimizeMode: row.settings.optimizeMode === "standard" || row.settings.optimizeMode === "fast" ? row.settings.optimizeMode : undefined
|
||||
} : {},
|
||||
sortOrder: optionalNumber(row.sort_order) ?? 0,
|
||||
createdAt: requiredTimestamp(row.created_at, "image_templates.created_at"),
|
||||
|
||||
@@ -60,12 +60,18 @@ function normalizeTemplateSettings(value: unknown): ImageTemplate["settings"] {
|
||||
const engine = normalizeImageEngine(value.engine);
|
||||
const scale = optionalScale(value.scale);
|
||||
const quality = normalizeQuality(value.quality);
|
||||
const size = normalizeSeedreamSize(value.size);
|
||||
const outputFormat = normalizeOutputFormat(value.outputFormat);
|
||||
const optimizeMode = normalizeOptimizeMode(value.optimizeMode);
|
||||
return {
|
||||
...(engine ? { engine } : {}),
|
||||
...(width && height ? { width, height } : {}),
|
||||
...(typeof value.forceSingle === "boolean" ? { forceSingle: value.forceSingle } : {}),
|
||||
...(scale !== undefined ? { scale } : {}),
|
||||
...(quality ? { quality } : {})
|
||||
...(quality ? { quality } : {}),
|
||||
...(size ? { size } : {}),
|
||||
...(outputFormat ? { outputFormat } : {}),
|
||||
...(optimizeMode ? { optimizeMode } : {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,7 +95,25 @@ function optionalScale(value: unknown): number | undefined {
|
||||
|
||||
function normalizeImageEngine(value: unknown): ImageTemplate["settings"]["engine"] | undefined {
|
||||
const text = optionalText(value, 20);
|
||||
if (text === "jimeng" || text === "evolink" || text === "bailian") return text;
|
||||
if (text === "jimeng" || text === "evolink" || text === "bailian" || text === "seedream") return text;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeSeedreamSize(value: unknown): ImageTemplate["settings"]["size"] | undefined {
|
||||
const text = optionalText(value, 10)?.toUpperCase();
|
||||
if (text === "1K" || text === "1.5K" || text === "2K") return text;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(value: unknown): ImageTemplate["settings"]["outputFormat"] | undefined {
|
||||
const text = optionalText(value, 10)?.toLowerCase();
|
||||
if (text === "png" || text === "jpeg") return text;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeOptimizeMode(value: unknown): ImageTemplate["settings"]["optimizeMode"] | undefined {
|
||||
const text = optionalText(value, 20)?.toLowerCase();
|
||||
if (text === "standard" || text === "fast") return text;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { VIDEO_DURATION_MAX } from "@/lib/video-settings";
|
||||
import { SEEDANCE_25_MODEL, VIDEO_DURATION_MAX, isSeedance25Model } from "@/lib/video-settings";
|
||||
|
||||
const SEEDANCE_FPS = 24;
|
||||
const SEEDANCE_TOKEN_SCALE = 1_000_000;
|
||||
|
||||
export type SeedanceBillingEstimateInput = {
|
||||
model?: unknown;
|
||||
resolution?: unknown;
|
||||
aspectRatio?: unknown;
|
||||
outputDurationSeconds: number;
|
||||
@@ -13,6 +14,7 @@ export type SeedanceBillingEstimateInput = {
|
||||
};
|
||||
|
||||
export type SeedanceActualBillingInput = {
|
||||
model?: unknown;
|
||||
resolution?: unknown;
|
||||
inputVideo?: boolean;
|
||||
completionTokens: number;
|
||||
@@ -20,11 +22,17 @@ export type SeedanceActualBillingInput = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 火山方舟文档的 Seedance 2.0 视频 token 单价,单位为“分/百万 token”。
|
||||
* 480p/720p、1080p 和 4K 的单价不同;含输入视频时使用对应的输入视频档。
|
||||
* 火山方舟文档的 Seedance 视频 token 单价,单位为“分/百万 token”。
|
||||
* 每个模型、分辨率及是否包含输入视频都有独立价格。
|
||||
*/
|
||||
export function seedanceTokenPriceFenPerMillion(resolution: unknown, inputVideo = false): number {
|
||||
export function seedanceTokenPriceFenPerMillion(model: unknown, resolution: unknown, inputVideo = false): number {
|
||||
const normalized = normalizeSeedanceResolution(resolution);
|
||||
if (String(model || "").trim() === SEEDANCE_25_MODEL) {
|
||||
const prices = normalized === "1080p"
|
||||
? { withoutVideo: 7_700, withVideo: 4_600 }
|
||||
: { withoutVideo: 7_000, withVideo: 4_200 };
|
||||
return inputVideo ? prices.withVideo : prices.withoutVideo;
|
||||
}
|
||||
const prices: Record<string, { withoutVideo: number; withVideo: number }> = {
|
||||
"480p": { withoutVideo: 4_600, withVideo: 2_800 },
|
||||
"720p": { withoutVideo: 4_600, withVideo: 2_800 },
|
||||
@@ -42,7 +50,7 @@ export function calculateSeedanceActualAmountFen(input: SeedanceActualBillingInp
|
||||
if (!Number.isFinite(input.markupMultiplier) || input.markupMultiplier < 1) {
|
||||
throw new Error("Seedance 计费倍率不能低于 1.00。");
|
||||
}
|
||||
const tokenPriceFenPerMillion = seedanceTokenPriceFenPerMillion(input.resolution, input.inputVideo);
|
||||
const tokenPriceFenPerMillion = seedanceTokenPriceFenPerMillion(input.model, input.resolution, input.inputVideo);
|
||||
return Math.max(1, Math.ceil(
|
||||
input.completionTokens * tokenPriceFenPerMillion * input.markupMultiplier / SEEDANCE_TOKEN_SCALE
|
||||
));
|
||||
@@ -54,7 +62,7 @@ export function estimateSeedanceAmountFen(input: SeedanceBillingEstimateInput):
|
||||
}
|
||||
const inputVideo = Boolean(input.inputVideo);
|
||||
const inputDuration = inputVideo
|
||||
? normalizeInputVideoDuration(input.inputVideoDurationSeconds)
|
||||
? normalizeInputVideoDuration(input.inputVideoDurationSeconds, input.model)
|
||||
: 0;
|
||||
const completionTokens = estimateSeedanceCompletionTokens({
|
||||
resolution: input.resolution,
|
||||
@@ -63,7 +71,7 @@ export function estimateSeedanceAmountFen(input: SeedanceBillingEstimateInput):
|
||||
inputVideoDurationSeconds: inputDuration
|
||||
});
|
||||
return Math.max(1, Math.ceil(
|
||||
completionTokens * seedanceTokenPriceFenPerMillion(input.resolution, inputVideo) * input.markupMultiplier / SEEDANCE_TOKEN_SCALE
|
||||
completionTokens * seedanceTokenPriceFenPerMillion(input.model, input.resolution, inputVideo) * input.markupMultiplier / SEEDANCE_TOKEN_SCALE
|
||||
));
|
||||
}
|
||||
|
||||
@@ -84,10 +92,11 @@ export function estimateSeedanceCompletionTokens(input: {
|
||||
/**
|
||||
* 没有媒体元数据时按官方可接收的最大输入视频时长冻结,避免先扣少、成功后补扣造成余额不足。
|
||||
*/
|
||||
export function normalizeInputVideoDuration(value: unknown): number {
|
||||
export function normalizeInputVideoDuration(value: unknown, model?: unknown): number {
|
||||
const duration = Number(value);
|
||||
if (!Number.isFinite(duration) || duration <= 0) return VIDEO_DURATION_MAX;
|
||||
return Math.min(VIDEO_DURATION_MAX, duration);
|
||||
const maximum = isSeedance25Model(String(model || "")) ? 30 : VIDEO_DURATION_MAX;
|
||||
if (!Number.isFinite(duration) || duration <= 0) return maximum;
|
||||
return Math.min(maximum, isSeedance25Model(String(model || "")) ? Math.max(4, duration) : duration);
|
||||
}
|
||||
|
||||
export function normalizeSeedanceResolution(value: unknown): string {
|
||||
@@ -116,7 +125,7 @@ export function requestHasInputVideo(requestPayload: Record<string, unknown>): b
|
||||
return requestMaterials(requestPayload).some((material) => String(material.type || "").toLowerCase() === "video");
|
||||
}
|
||||
|
||||
export function requestInputVideoDurationSeconds(requestPayload: Record<string, unknown>): number | undefined {
|
||||
export function requestInputVideoDurationSeconds(requestPayload: Record<string, unknown>, model?: unknown): number | undefined {
|
||||
const videoMaterials = requestMaterials(requestPayload)
|
||||
.filter((material) => String(material.type || "").toLowerCase() === "video");
|
||||
if (!videoMaterials.length) return undefined;
|
||||
@@ -129,7 +138,7 @@ export function requestInputVideoDurationSeconds(requestPayload: Record<string,
|
||||
))
|
||||
.filter((value): value is number => value !== undefined && value > 0);
|
||||
if (!knownDurations.length) return undefined;
|
||||
return Math.min(VIDEO_DURATION_MAX, knownDurations.reduce((sum, value) => sum + value, 0));
|
||||
return normalizeInputVideoDuration(knownDurations.reduce((sum, value) => sum + value, 0), model);
|
||||
}
|
||||
|
||||
function requestMaterials(requestPayload: Record<string, unknown>): Array<Record<string, unknown>> {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const SEEDREAM_50_PRO_MODEL = "doubao-seedream-5-0-pro-260628";
|
||||
|
||||
export function calculateSeedreamAmountFen(input: {
|
||||
outputPriceFen: number;
|
||||
referenceImageCount: number;
|
||||
markupMultiplier: number;
|
||||
}): number {
|
||||
if (!Number.isFinite(input.outputPriceFen) || input.outputPriceFen <= 0) throw new Error("Seedream 输出价格必须大于 0。");
|
||||
if (!Number.isInteger(input.referenceImageCount) || input.referenceImageCount < 0 || input.referenceImageCount > 10) {
|
||||
throw new Error("Seedream 参考图数量必须在 0 到 10 之间。");
|
||||
}
|
||||
if (!Number.isFinite(input.markupMultiplier) || input.markupMultiplier < 1) throw new Error("Seedream 上浮倍率不能低于 1.00。");
|
||||
const extraInputPriceFen = Math.max(0, input.referenceImageCount - 1) * 2;
|
||||
return Math.ceil((input.outputPriceFen + extraInputPriceFen) * input.markupMultiplier);
|
||||
}
|
||||
@@ -10,12 +10,13 @@ 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 { normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution } from "@/lib/video-settings";
|
||||
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";
|
||||
|
||||
export type VideoCreationEngine = "seedance" | "bailian";
|
||||
|
||||
export type SubmitVideoJobInput = PromptAssemblyInput & {
|
||||
model?: string;
|
||||
ownerId?: string;
|
||||
externalClientId?: string;
|
||||
prompt?: string;
|
||||
@@ -114,6 +115,10 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
|
||||
? input.engine
|
||||
: configuredEngine;
|
||||
const config = getSeedanceConfig();
|
||||
if (engine === "seedance" && input.model !== undefined && !isSupportedSeedanceModel(input.model)) {
|
||||
throw new Error("不支持的 Seedance 视频模型。");
|
||||
}
|
||||
const model = normalizeSeedanceModel(input.model, config.model);
|
||||
const assembled = assemblePrompt({
|
||||
...input,
|
||||
mode: "video",
|
||||
@@ -127,15 +132,17 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
|
||||
buildBailianVideoPayload({ prompt: finalPrompt, materials: assembled.materials, origin, settings });
|
||||
} else {
|
||||
settings.ratio = normalizeVideoRatio(settings.ratio, config.ratio);
|
||||
settings.duration = normalizeVideoDuration(settings.duration) ?? config.duration;
|
||||
settings.resolution = normalizeVideoResolution(settings.resolution, config.model, config.resolution);
|
||||
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 requestPayload = {
|
||||
input,
|
||||
assembled,
|
||||
settings,
|
||||
engine
|
||||
engine,
|
||||
...(engine === "seedance" ? { model } : {})
|
||||
};
|
||||
return {
|
||||
ownerId,
|
||||
@@ -146,7 +153,7 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
|
||||
settings,
|
||||
missingProviderCredentials,
|
||||
provider: engine,
|
||||
reqKey: engine === "bailian" ? getBailianConfig().videoModel : config.model,
|
||||
reqKey: engine === "bailian" ? getBailianConfig().videoModel : model,
|
||||
requestPayload
|
||||
};
|
||||
}
|
||||
@@ -180,7 +187,7 @@ 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 });
|
||||
}
|
||||
const response = await createSeedanceTask({ prompt: job.prompt || "", settings, materials, origin });
|
||||
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) {
|
||||
job = await updateGenerationJob(job.id, {
|
||||
|
||||
Reference in new issue
Block a user