import { modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; export const SEEDANCE_REFERENCE_LIMITS = { images: 9, videos: 3, audios: 3, imageMaxBytes: 30 * 1024 * 1024, videoMaxBytes: 50 * 1024 * 1024, audioMaxBytes: 15 * 1024 * 1024, }; export const seedanceResolutionOptions = [ { value: "480p", label: "480p" }, { value: "720p", label: "720p" }, { value: "1080p", label: "1080p" }, ] as const; export const seedanceRatioOptions = [ { value: "16:9", label: "横屏" }, { value: "9:16", label: "竖屏" }, { value: "1:1", label: "方形" }, { value: "4:3", label: "标准横屏" }, { value: "3:4", label: "标准竖屏" }, { value: "21:9", label: "宽银幕" }, { value: "adaptive", label: "自适应" }, ] as const; export const seedanceDurationOptions = [-1, 4, 5, 6, 8, 10, 12, 15] as const; const seedancePixels = { "480p": { "16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432", }, "720p": { "16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630", }, "1080p": { "16:9": "1920x1080", "4:3": "1664x1248", "1:1": "1440x1440", "3:4": "1248x1664", "9:16": "1080x1920", "21:9": "2206x946", }, } as const; export function isSeedanceVideoConfig(config: AiConfig | Pick) { const requestConfig = "channels" in config ? resolveModelRequestConfig(config, config.model || config.videoModel) : config; return isSeedanceVideoModel(modelOptionName(requestConfig.model || requestConfig.videoModel)) || isArkPlanBaseUrl(requestConfig.baseUrl); } export function isSeedanceVideoModel(model: string) { const value = model.toLowerCase(); return value.includes("seedance") || value.includes("doubao-seedance"); } export function isSeedanceFastModel(model: string) { const value = model.toLowerCase(); return isSeedanceVideoModel(value) && value.includes("fast"); } export function isArkPlanBaseUrl(baseUrl: string) { return baseUrl.toLowerCase().includes("ark.cn-beijing.volces.com/api/plan/v3") || baseUrl.toLowerCase().includes("/api/plan/v3"); } export function normalizeSeedanceResolution(value: string, model = "") { const normalized = normalizeResolutionToken(value); if (isSeedanceFastModel(model) && normalized === "1080p") return "720p"; return seedanceResolutionOptions.some((item) => item.value === normalized) ? normalized : "720p"; } export function normalizeResolutionToken(value: string) { if (value === "low") return "480p"; if (value === "auto" || value === "high" || value === "medium") return "720p"; const resolution = String(value || "").replace(/p$/i, "") || "720"; return `${resolution}p`; } export function normalizeSeedanceDuration(value: string) { if (String(value).trim() === "-1") return -1; const seconds = Math.floor(Number(value) || 5); return Math.max(4, Math.min(15, seconds)); } export function normalizeSeedanceRatio(value: string) { if (!value || value === "auto" || value === "adaptive") return "adaptive"; if (seedanceRatioOptions.some((item) => item.value === value)) return value; const match = value.match(/^(\d+)x(\d+)$/); if (!match) return "adaptive"; const width = Number(match[1]); const height = Number(match[2]); if (!width || !height) return "adaptive"; const ratio = width / height; const options = [ ["16:9", 16 / 9], ["4:3", 4 / 3], ["1:1", 1], ["3:4", 3 / 4], ["9:16", 9 / 16], ["21:9", 21 / 9], ] as const; return options.reduce((best, item) => (Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best), options[0])[0]; } export function seedancePixelLabel(resolution: string, ratio: string) { const normalizedResolution = normalizeSeedanceResolution(resolution) as keyof typeof seedancePixels; const normalizedRatio = normalizeSeedanceRatio(ratio) as keyof (typeof seedancePixels)[typeof normalizedResolution] | "adaptive"; if (normalizedRatio === "adaptive") return "自动匹配"; return seedancePixels[normalizedResolution][normalizedRatio] || ""; } export function boolConfig(value: string | undefined, fallback: boolean) { if (value === "true") return true; if (value === "false") return false; return fallback; } export function seedanceReferenceLabel(kind: "image" | "video" | "audio", index: number) { if (kind === "image") return `图片${index + 1}`; if (kind === "video") return `视频${index + 1}`; return `音频${index + 1}`; } export function buildSeedancePromptText(prompt: string, images: ReferenceImage[], videos: ReferenceVideo[], audios: ReferenceAudio[]) { const labels = [ ...images.map((_, index) => seedanceReferenceLabel("image", index)), ...videos.map((_, index) => seedanceReferenceLabel("video", index)), ...audios.map((_, index) => seedanceReferenceLabel("audio", index)), ]; const text = prompt.trim(); if (!labels.length) return text; return `参考资产编号:${labels.join("、")}。请按这些编号理解提示词中的图片、视频和音频引用。\n\n${text}`; } export function seedanceVideoReferenceError(videos: ReferenceVideo[]) { let totalDurationMs = 0; for (let index = 0; index < videos.length; index += 1) { const video = videos[index]; const label = seedanceReferenceLabel("video", index); if (video.bytes && video.bytes > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes) return `${label} 超过 50MB,请压缩后再上传`; if (video.durationMs) { if (video.durationMs < 2000 || video.durationMs > 15000) return `${label} 时长需要在 2-15 秒之间`; totalDurationMs += video.durationMs; } if (video.width && video.height) { if (video.width < 300 || video.width > 6000 || video.height < 300 || video.height > 6000) return `${label} 宽高需要在 300-6000px 之间`; const ratio = video.width / video.height; if (ratio < 0.4 || ratio > 2.5) return `${label} 宽高比需要在 0.4-2.5 之间`; const pixels = video.width * video.height; if (pixels < 640 * 640 || pixels > 2206 * 946) return `${label} 像素总量不符合 Seedance 要求,请转成 480p/720p/1080p 后再上传`; } } if (totalDurationMs > 15000) return "Seedance 参考视频总时长不能超过 15 秒"; return ""; } export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸资产请使用火山授权 asset:// 资产。";