import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; import { isVolcengineArkVideoProtocol } from "@/lib/model-protocols"; import { boolConfig, buildSeedancePromptText, isArkPlanBaseUrl, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video"; import { getResourceOSSUrl } from "@/services/api/resources"; import { getMediaBlob } from "@/services/file-storage"; import { imageToDataUrl } from "@/services/image-storage"; import { buildApiUrl, modelOptionName, type AiConfig } from "@/stores/use-config-store"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; import { isPublicMediaUrl } from "./video-validation"; import type { ApiEnvelope, RequestOptions, ResolvedAiConfig, SeedanceTask, VideoGenerationTask, VideoGenerationTaskState } from "./video-contracts"; import type { VideoProviderDeps } from "./video-provider-deps"; import { hasExplicitVideoFrames, resolveVideoImageReferences } from "./video-reference-roles"; export function isSeedanceConfig(config: ResolvedAiConfig) { return isVolcengineArkVideoProtocol(config.interfaceType) || isSeedanceVideoConfig(config); } export async function createSeedanceTask(deps: VideoProviderDeps, config: ResolvedAiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions): Promise { assertSeedanceVideoReferences(videoReferences); assertSeedanceAudioReferences(audioReferences); const isVolcengineArk = isVolcengineArkVideoProtocol(config.interfaceType); const payload = isVolcengineArk || isArkPlanBaseUrl(config.baseUrl) ? await buildSeedanceAgentPlanPayload(config, model, prompt, references, videoReferences, audioReferences, deps, options) : await buildSeedanceVideosPayload(config, model, prompt, references, videoReferences, audioReferences, deps, options); try { const raw = await deps.transport.post>(seedanceApiUrl(config), payload, options); const created = deps.response.unwrapSeedanceTask(raw); const id = created.id || created.task_id; if (!id) throw new Error("Seedance 接口没有返回任务 ID"); return { id, provider: "seedance", model }; } catch (error) { throw new Error(deps.response.readAxiosError(error, "Seedance 任务创建失败")); } } export async function pollSeedanceTask(deps: VideoProviderDeps, config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise { try { const raw = await deps.transport.get>(seedanceApiUrl(config, task.id), options); const state = deps.response.unwrapSeedanceTask(raw); if (state.status === "succeeded" || state.status === "completed") { const url = state.video_url || state.content?.video_url; if (url) return { status: "completed", result: await deps.response.videoResultFromUrl(url, options) }; if (isArkPlanBaseUrl(config.baseUrl)) return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" }; const content = await deps.transport.getBlob(deps.transport.apiUrl(`/videos/${task.id}/content`), options); await deps.response.assertVideoBlob(content); return { status: "completed", result: { blob: content } }; } if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: seedanceErrorMessage(state) || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` }; return { status: "pending" }; } catch (error) { throw new Error(deps.response.readAxiosError(error, "Seedance 任务查询失败")); } } function assertSeedanceVideoReferences(videoReferences: ReferenceVideo[]) { const error = seedanceVideoReferenceError(videoReferences); if (error) throw new Error(error); let total = 0; for (const video of videoReferences) { if (!video.durationMs) continue; if (video.durationMs < 2000 || video.durationMs > 15000) throw new Error("Seedance 参考视频单个时长需要在 2-15 秒之间"); total += video.durationMs; } if (total > 15000) throw new Error("Seedance 参考视频总时长不能超过 15 秒"); } function assertSeedanceAudioReferences(audioReferences: ReferenceAudio[]) { let total = 0; for (const audio of audioReferences) { if (!audio.durationMs) continue; if (audio.durationMs < 2000 || audio.durationMs > 15000) throw new Error("Seedance 参考音频单个时长需要在 2-15 秒之间"); total += audio.durationMs; } if (total > 15000) throw new Error("Seedance 参考音频总时长不能超过 15 秒"); } function seedanceApiUrl(config: ResolvedAiConfig, taskId?: string) { if (isVolcengineArkVideoProtocol(config.interfaceType) || isArkPlanBaseUrl(config.baseUrl)) return buildApiUrl(config.baseUrl, `/contents/generations/tasks${taskId ? `/${encodeURIComponent(taskId)}` : ""}`); return buildApiUrl(config.baseUrl, `/videos${taskId ? `/${encodeURIComponent(taskId)}` : ""}`); } async function buildSeedanceAgentPlanPayload(config: ResolvedAiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], deps: VideoProviderDeps, options?: RequestOptions) { if (audioReferences.length && !references.length && !videoReferences.length) { throw new Error(isVolcengineArkVideoProtocol(config.interfaceType) ? "火山方舟全模态参考不支持纯音频或文本+音频,请同时添加参考图片或参考视频" : "Seedance 参考音频不能单独使用,请同时添加参考图或参考视频"); } const content = isVolcengineArkVideoProtocol(config.interfaceType) ? await buildVolcengineArkContent(prompt, references, videoReferences, audioReferences, options) : await buildSeedanceContent(config, prompt, references, videoReferences, audioReferences, deps, options); if (!content.length) throw new Error("请输入视频提示词,或连接参考图片/视频/音频"); const profile = modelCapabilityConfigFor(config, model).video!; return { model: modelOptionName(model), content, ratio: normalizeSeedanceRatio(config.size), resolution: normalizeSeedanceResolution(config.vquality, modelOptionName(model)), duration: normalizeSeedanceDuration(config.videoSeconds), ...(profile.generateAudio.supported ? { generate_audio: boolConfig(config.videoGenerateAudio, profile.generateAudio.default) } : {}), ...(profile.watermark.supported ? { watermark: boolConfig(config.videoWatermark, profile.watermark.default) } : {}), }; } async function buildVolcengineArkContent(prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions) { const content: Array> = []; const imagePlan = resolveVideoImageReferences(references.slice(0, SEEDANCE_REFERENCE_LIMITS.images), options, { videoCount: videoReferences.length, audioCount: audioReferences.length }); if (prompt.trim()) content.push({ type: "text", text: prompt.trim() }); for (const { image, role } of imagePlan) { content.push({ type: "image_url", image_url: { url: await resolveVolcengineArkReferenceUrl(image.arkAssetId ? `asset://${image.arkAssetId}` : image.url || image.dataUrl, image.storageKey) }, role }); } for (const video of videoReferences.slice(0, SEEDANCE_REFERENCE_LIMITS.videos)) { content.push({ type: "video_url", video_url: { url: await resolveVolcengineArkReferenceUrl(video.url, video.storageKey) }, role: "reference_video" }); } for (const audio of audioReferences.slice(0, SEEDANCE_REFERENCE_LIMITS.audios)) { content.push({ type: "audio_url", audio_url: { url: await resolveVolcengineArkReferenceUrl(audio.url, audio.storageKey) }, role: "reference_audio" }); } return content; } async function resolveVolcengineArkReferenceUrl(value: string | undefined, storageKey?: string) { // 已录入的方舟素材 ID 优先:已过审或被授权的素材直接引用,无需再换对象存储地址。 if (String(value || "").startsWith("asset://")) return String(value); if (storageKey?.startsWith("resource:")) return getResourceOSSUrl(storageKey); if (isPublicMediaUrl(value || "")) return String(value); throw new Error("火山方舟视频参考素材需要公网 URL 或 asset:// 素材 ID;请先将本地素材保存到对象存储"); } async function buildSeedanceVideosPayload(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], deps: VideoProviderDeps, options?: RequestOptions) { if ((videoReferences.length || audioReferences.length) && !references.length) { throw new Error("Seedance 参考视频或参考音频需要同时连接至少 1 张主参考图"); } const imageUrls = await Promise.all(references.slice(0, SEEDANCE_REFERENCE_LIMITS.images).map(resolveSeedanceVideosImageUrl)); const imagePlan = resolveVideoImageReferences(references.slice(0, SEEDANCE_REFERENCE_LIMITS.images), options, { videoCount: videoReferences.length, audioCount: audioReferences.length }); const videoUrls = await Promise.all(videoReferences.slice(0, SEEDANCE_REFERENCE_LIMITS.videos).map((media) => resolveSeedanceVideosMediaUrl(media, deps))); const audioUrls = await Promise.all(audioReferences.slice(0, SEEDANCE_REFERENCE_LIMITS.audios).map((media) => resolveSeedanceVideosMediaUrl(media, deps))); const ratio = normalizeSeedanceRatio(config.size); const duration = normalizeSeedanceDuration(config.videoSeconds); const profile = modelCapabilityConfigFor(config, model).video!; const imagePayload: Record = {}; if (options?.videoEditOperation === "reference_to_video") { if (imageUrls.length) imagePayload.reference_image_urls = imageUrls; } else if (hasExplicitVideoFrames(options)) { imagePayload.image_urls = orderedImageUrls(imageUrls, imagePlan.map(({ role }) => role)); } else if (imageUrls.length) { imagePayload.image_url = imageUrls[0]; if (imageUrls.length > 1) imagePayload.reference_image_urls = imageUrls.slice(1); } return { model: modelOptionName(model), prompt: prompt.trim(), aspect_ratio: ratio === "adaptive" ? "16:9" : ratio, duration, ...(profile.generateAudio.supported ? { generate_audio: boolConfig(config.videoGenerateAudio, profile.generateAudio.default) } : {}), ...imagePayload, ...(videoUrls.length ? { reference_videos: videoUrls } : {}), ...(audioUrls.length ? { reference_audios: audioUrls } : {}), }; } async function buildSeedanceContent(config: AiConfig, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], deps: VideoProviderDeps, options?: RequestOptions) { const content: Array> = []; const imagePlan = resolveVideoImageReferences(references.slice(0, SEEDANCE_REFERENCE_LIMITS.images), options, { videoCount: videoReferences.length, audioCount: audioReferences.length }); const text = buildSeedancePromptText(prompt, references, videoReferences, audioReferences); if (text) content.push({ type: "text", text }); for (const { image, role } of imagePlan) { content.push({ type: "image_url", image_url: { url: await resolveSeedanceImageUrl(image) }, role }); } for (const video of videoReferences.slice(0, SEEDANCE_REFERENCE_LIMITS.videos)) { content.push({ type: "video_url", video_url: { url: await resolveSeedanceMediaUrl(video, deps, "参考视频") }, role: "reference_video" }); } for (const audio of audioReferences.slice(0, SEEDANCE_REFERENCE_LIMITS.audios)) { content.push({ type: "audio_url", audio_url: { url: await resolveSeedanceMediaUrl(audio, deps, "参考音频") }, role: "reference_audio" }); } return content; } function orderedImageUrls(imageUrls: string[], roles: Array<"first_frame" | "last_frame" | "reference_image">) { return ["first_frame", "last_frame", "reference_image"].flatMap((role) => imageUrls.filter((_, index) => roles[index] === role)); } async function resolveSeedanceImageUrl(image: ReferenceImage) { const directUrl = image.url || image.dataUrl; if (isPublicMediaUrl(directUrl) || directUrl.startsWith("asset://")) return directUrl; const dataUrl = await imageToDataUrl(image); if (!dataUrl) throw new Error("参考图读取失败,请换一张图片或重新上传"); return dataUrl; } async function resolveSeedanceVideosImageUrl(image: ReferenceImage) { const directUrl = image.url || image.dataUrl; if (isPublicMediaUrl(directUrl) || directUrl.startsWith("data:")) return directUrl; const dataUrl = await imageToDataUrl(image); if (!dataUrl) throw new Error("参考图读取失败,请换一张图片或重新上传"); return dataUrl; } async function resolveSeedanceMediaUrl(media: ReferenceVideo | ReferenceAudio, deps: VideoProviderDeps, label: string) { if (isPublicMediaUrl(media.url) || media.url.startsWith("asset://")) return media.url; let blob: Blob | null = null; if (media.storageKey) blob = await getMediaBlob(media.storageKey); if (!blob && media.url?.startsWith("blob:")) blob = await (await fetch(media.url)).blob(); if (!blob) throw new Error(`${label}必须是公网 URL、素材 ID,或本地已保存素材`); return deps.response.blobToDataUrl(blob); } async function resolveSeedanceVideosMediaUrl(media: ReferenceVideo | ReferenceAudio, deps: VideoProviderDeps) { if (isPublicMediaUrl(media.url) || media.url?.startsWith("data:")) return media.url; let blob: Blob | null = null; if (media.storageKey) blob = await getMediaBlob(media.storageKey); if (!blob && media.url?.startsWith("blob:")) blob = await (await fetch(media.url)).blob(); if (!blob) throw new Error("Seedance /videos 参考素材必须是公网 URL、data URL,或本地已保存素材"); return deps.response.blobToDataUrl(blob); } function seedanceErrorMessage(state: SeedanceTask) { if (state.error?.message && state.error.code) return `${state.error.code}:${state.error.message}`; return state.error?.message || state.error_code || ""; }