Files
NianAIGC/lib/server/seedance-billing.ts

172 lines
6.9 KiB
TypeScript

import { VIDEO_DURATION_MAX } from "@/lib/video-settings";
const SEEDANCE_FPS = 24;
const SEEDANCE_TOKEN_SCALE = 1_000_000;
export type SeedanceBillingEstimateInput = {
resolution?: unknown;
aspectRatio?: unknown;
outputDurationSeconds: number;
inputVideoDurationSeconds?: number;
inputVideo?: boolean;
markupMultiplier: number;
};
export type SeedanceActualBillingInput = {
resolution?: unknown;
inputVideo?: boolean;
completionTokens: number;
markupMultiplier: number;
};
/**
* 火山方舟文档的 Seedance 2.0 视频 token 单价,单位为“分/百万 token”。
* 480p/720p、1080p 和 4K 的单价不同;含输入视频时使用对应的输入视频档。
*/
export function seedanceTokenPriceFenPerMillion(resolution: unknown, inputVideo = false): number {
const normalized = normalizeSeedanceResolution(resolution);
const prices: Record<string, { withoutVideo: number; withVideo: number }> = {
"480p": { withoutVideo: 4_600, withVideo: 2_800 },
"720p": { withoutVideo: 4_600, withVideo: 2_800 },
"1080p": { withoutVideo: 5_100, withVideo: 3_100 },
"4k": { withoutVideo: 2_600, withVideo: 1_600 }
};
const price = prices[normalized] || prices["720p"];
return inputVideo ? price.withVideo : price.withoutVideo;
}
export function calculateSeedanceActualAmountFen(input: SeedanceActualBillingInput): number {
if (!Number.isFinite(input.completionTokens) || input.completionTokens <= 0) {
throw new Error("Seedance 实际 token 用量必须大于 0。");
}
if (!Number.isFinite(input.markupMultiplier) || input.markupMultiplier < 1) {
throw new Error("Seedance 计费倍率不能低于 1.00。");
}
const tokenPriceFenPerMillion = seedanceTokenPriceFenPerMillion(input.resolution, input.inputVideo);
return Math.max(1, Math.ceil(
input.completionTokens * tokenPriceFenPerMillion * input.markupMultiplier / SEEDANCE_TOKEN_SCALE
));
}
export function estimateSeedanceAmountFen(input: SeedanceBillingEstimateInput): number {
if (!Number.isFinite(input.outputDurationSeconds) || input.outputDurationSeconds <= 0) {
throw new Error("Seedance 输出时长必须大于 0。");
}
const inputVideo = Boolean(input.inputVideo);
const inputDuration = inputVideo
? normalizeInputVideoDuration(input.inputVideoDurationSeconds)
: 0;
const completionTokens = estimateSeedanceCompletionTokens({
resolution: input.resolution,
aspectRatio: input.aspectRatio,
outputDurationSeconds: input.outputDurationSeconds,
inputVideoDurationSeconds: inputDuration
});
return Math.max(1, Math.ceil(
completionTokens * seedanceTokenPriceFenPerMillion(input.resolution, inputVideo) * input.markupMultiplier / SEEDANCE_TOKEN_SCALE
));
}
export function estimateSeedanceCompletionTokens(input: {
resolution?: unknown;
aspectRatio?: unknown;
outputDurationSeconds: number;
inputVideoDurationSeconds?: number;
}): number {
const dimensions = seedanceOutputDimensions(input.resolution, input.aspectRatio);
const inputDuration = Math.max(0, Number(input.inputVideoDurationSeconds) || 0);
const outputDuration = Math.max(0, Number(input.outputDurationSeconds) || 0);
return Math.ceil(
(inputDuration + outputDuration) * dimensions.width * dimensions.height * SEEDANCE_FPS / 1024
);
}
/**
* 没有媒体元数据时按官方可接收的最大输入视频时长冻结,避免先扣少、成功后补扣造成余额不足。
*/
export function normalizeInputVideoDuration(value: unknown): number {
const duration = Number(value);
if (!Number.isFinite(duration) || duration <= 0) return VIDEO_DURATION_MAX;
return Math.min(VIDEO_DURATION_MAX, duration);
}
export function normalizeSeedanceResolution(value: unknown): string {
const normalized = String(value || "720p").trim().toLowerCase();
if (normalized === "4k" || normalized === "4K".toLowerCase()) return "4k";
if (normalized === "480p" || normalized === "1080p") return normalized;
return "720p";
}
export function seedanceOutputDimensions(resolution: unknown, aspectRatio: unknown): { width: number; height: number } {
const base = {
"480p": { width: 854, height: 480 },
"720p": { width: 1280, height: 720 },
"1080p": { width: 1920, height: 1080 },
"4k": { width: 3840, height: 2160 }
}[normalizeSeedanceResolution(resolution)] || { width: 1280, height: 720 };
const ratio = parseAspectRatio(aspectRatio);
const area = base.width * base.height;
return {
width: Math.max(1, Math.round(Math.sqrt(area * ratio))),
height: Math.max(1, Math.round(Math.sqrt(area / ratio)))
};
}
export function requestHasInputVideo(requestPayload: Record<string, unknown>): boolean {
return requestMaterials(requestPayload).some((material) => String(material.type || "").toLowerCase() === "video");
}
export function requestInputVideoDurationSeconds(requestPayload: Record<string, unknown>): number | undefined {
const videoMaterials = requestMaterials(requestPayload)
.filter((material) => String(material.type || "").toLowerCase() === "video");
if (!videoMaterials.length) return undefined;
const knownDurations = videoMaterials
.map((material) => firstFiniteNumber(
material.duration,
material.durationSeconds,
recordValue(material.metadata)?.duration,
recordValue(material.metadata)?.durationSeconds
))
.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));
}
function requestMaterials(requestPayload: Record<string, unknown>): Array<Record<string, unknown>> {
const assembled = recordValue(requestPayload.assembled);
const input = recordValue(requestPayload.input);
const assembledMaterials = recordArray(assembled?.materials);
const inputMaterials = recordArray(input?.materials);
const materials = assembledMaterials.length ? assembledMaterials : inputMaterials;
return materials;
}
function parseAspectRatio(value: unknown): number {
const normalized = String(value || "16:9").trim().toLowerCase();
const match = normalized.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/);
if (!match) return 16 / 9;
const width = Number(match[1]);
const height = Number(match[2]);
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
? width / height
: 16 / 9;
}
function recordArray(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? value.map(recordValue).filter((item): item is Record<string, unknown> => Boolean(item)) : [];
}
function recordValue(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
}
function firstFiniteNumber(...values: unknown[]): number | undefined {
for (const value of values) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}