Files
ttstd-infinite-canvas/web/src/lib/seedance-video.ts

117 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { normalizeVideoDuration, VIDEO_DURATION_OPTIONS } from "@/lib/video-generation-options";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
// Existing channel contract values; these are not a claim about every provider's current limits.
export const SEEDANCE_REFERENCE_LIMITS = { images: 9, videos: 3, audios: 3, imageMaxBytes: 30 * 1048576, videoMaxBytes: 50 * 1048576, audioMaxBytes: 15 * 1048576 };
export const seedanceResolutionOptions = [
{ label: "480P", value: "480p" },
{ label: "720P", value: "720p" },
{ label: "1080P", value: "1080p" },
] as const;
export const seedanceRatioOptions = [
{ label: "横屏", 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" },
] as const;
export const seedanceDurationOptions = VIDEO_DURATION_OPTIONS;
const rasterRows: Record<string, readonly string[]> = {
"16:9": ["864x496", "1280x720", "1920x1080"],
"4:3": ["752x560", "1112x834", "1664x1248"],
"1:1": ["640x640", "960x960", "1440x1440"],
"3:4": ["560x752", "834x1112", "1248x1664"],
"9:16": ["496x864", "720x1280", "1080x1920"],
"21:9": ["992x432", "1470x630", "2206x946"],
};
const ratios = Object.keys(rasterRows).map((label) => {
const [w, h] = label.split(":").map(Number);
return { label, ratio: w / h };
});
export function isSeedanceVideoModel(model: string): boolean {
return /seedance/i.test(model);
}
export function isSeedanceFastModel(model: string): boolean {
return isSeedanceVideoModel(model) && /fast/i.test(model);
}
export function isArkPlanBaseUrl(baseUrl: string): boolean {
return /\/api\/plan\/v3/i.test(baseUrl);
}
export function isSeedanceVideoConfig(config: AiConfig | Pick<AiConfig, "model" | "videoModel" | "baseUrl">): boolean {
const request = "channels" in config ? resolveModelRequestConfig(config, config.model || config.videoModel) : config;
if ("interfaceType" in request && ["volcengine-ark-image", "volcengine-ark-agent-plan-image"].includes(String(request.interfaceType))) return false;
return isArkPlanBaseUrl(request.baseUrl || "") || isSeedanceVideoModel(modelOptionName(request.model || request.videoModel));
}
export function normalizeResolutionToken(value: string): string {
const token = String(value || "").toLowerCase();
const aliases: Record<string, string> = { low: "480p", auto: "720p", high: "720p", medium: "720p", "4k": "2160p", "": "720p" };
return aliases[token] || `${token.replace(/p$/, "")}p`;
}
export function normalizeSeedanceResolution(value: string, model = ""): string {
const requested = normalizeResolutionToken(value);
const supported = isSeedanceFastModel(model) ? ["480p", "720p"] : ["480p", "720p", "1080p", "2160p"];
return supported.includes(requested) ? requested : "720p";
}
export function normalizeSeedanceDuration(value: string): number {
return +normalizeVideoDuration(value);
}
export function normalizeSeedanceRatio(value: string): string {
if (Object.hasOwn(rasterRows, value)) return value;
const size = /^(\d+)x(\d+)$/.exec(value);
if (!size || +size[1] === 0 || +size[2] === 0) return "adaptive";
const requested = +size[1] / +size[2];
let nearest = ratios[0];
let distance = Infinity;
for (const entry of ratios) {
const next = Math.abs(entry.ratio - requested);
if (next < distance) {
distance = next;
nearest = entry;
}
}
return nearest.label;
}
export function seedancePixelLabel(resolution: string, ratio: string): string {
const aspect = normalizeSeedanceRatio(ratio);
if (aspect === "adaptive") return "自动匹配";
const column = ["480p", "720p", "1080p"].indexOf(normalizeSeedanceResolution(resolution));
return column < 0 ? "" : rasterRows[aspect]?.[column] || "";
}
export function boolConfig(value: string | undefined, fallback: boolean): boolean {
return value === "true" || (value !== "false" && fallback);
}
export function seedanceReferenceLabel(kind: "image" | "video" | "audio", index: number): string {
return `${{ image: "图片", video: "视频", audio: "音频" }[kind]}${index + 1}`;
}
export function buildSeedancePromptText(prompt: string, _images: ReferenceImage[], _videos: ReferenceVideo[], _audios: ReferenceAudio[]): string {
return prompt.trim();
}
type VideoRule = { valid: (video: ReferenceVideo) => boolean; message: string };
const videoRules: VideoRule[] = [
{ valid: (v) => !v.bytes || v.bytes <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes, message: "超过 50MB,请压缩后再上传" },
{ valid: (v) => !v.durationMs || (v.durationMs >= 2000 && v.durationMs <= 15000), message: "时长需要在 2-15 秒之间" },
{ valid: (v) => !v.width || !v.height || [v.width, v.height].every((edge) => edge >= 300 && edge <= 6000), message: "宽高需要在 300-6000px 之间" },
{ valid: (v) => !v.width || !v.height || (v.width / v.height >= 0.4 && v.width / v.height <= 2.5), message: "宽高比需要在 0.4-2.5 之间" },
{ valid: (v) => !v.width || !v.height || (v.width * v.height >= 409600 && v.width * v.height <= 2086876), message: "像素总量不符合 Seedance 要求,请转成 480p/720p/1080p 后再上传" },
];
export function seedanceVideoReferenceError(videos: ReferenceVideo[]): string {
for (const [index, video] of videos.entries()) {
const failed = videoRules.find((rule) => !rule.valid(video));
if (failed) return `${seedanceReferenceLabel("video", index)} ${failed.message}`;
}
const duration = videos.reduce((total, video) => total + (video.durationMs || 0), 0);
return duration > 15000 ? "Seedance 参考视频总时长不能超过 15 秒" : "";
}
export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸素材请使用火山授权 asset:// 素材。";