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 = { "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): boolean { return requestMaterials(requestPayload).some((material) => String(material.type || "").toLowerCase() === "video"); } export function requestInputVideoDurationSeconds(requestPayload: Record): 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): Array> { 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> { return Array.isArray(value) ? value.map(recordValue).filter((item): item is Record => Boolean(item)) : []; } function recordValue(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : undefined; } function firstFiniteNumber(...values: unknown[]): number | undefined { for (const value of values) { const parsed = Number(value); if (Number.isFinite(parsed)) return parsed; } return undefined; }