172 lines
6.9 KiB
TypeScript
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;
|
|
}
|