feat: add local auth billing and usage management
This commit is contained in:
1 parent
f642b5e71f
commit
196fdde83f
119 files changed
+15695
-2650
No files matched your search
@@ -0,0 +1,171 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user