fix(canvas): 视频参考协议 - 统一首尾帧与角色资产语义
This commit is contained in:
@@ -5,6 +5,7 @@ import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
import type { ApiEnvelope, RequestOptions, ResolvedAiConfig, VideoGenerationTask, VideoGenerationTaskState } from "./video-contracts";
|
||||
import type { VideoProviderDeps } from "./video-provider-deps";
|
||||
import { resolveVideoImageReferences, type VideoImageRole } from "./video-reference-roles";
|
||||
|
||||
type AgnesTaskResponse = {
|
||||
id?: string;
|
||||
@@ -44,9 +45,10 @@ export async function createAgnesVideoTask(
|
||||
Promise.all(videoReferences.map((item) => resolveAgnesMediaUrl(item.url, item.storageKey))),
|
||||
Promise.all(audioReferences.map((item) => resolveAgnesMediaUrl(item.url, item.storageKey))),
|
||||
]);
|
||||
const imageRoles = resolveVideoImageReferences(references, options, { videoCount: videoUrls.length, audioCount: audioUrls.length }).map(({ role }) => role);
|
||||
const payload = modelName === "agnes-video-v2.0"
|
||||
? buildAgnesV20Payload(config, modelName, normalizedPrompt, imageUrls, videoUrls, audioUrls)
|
||||
: buildAgnesV25Payload(config, modelName, normalizedPrompt, imageUrls, videoUrls, audioUrls);
|
||||
? buildAgnesV20Payload(config, modelName, normalizedPrompt, imageUrls, imageRoles, videoUrls, audioUrls, options)
|
||||
: buildAgnesV25Payload(config, modelName, normalizedPrompt, imageUrls, imageRoles, videoUrls, audioUrls);
|
||||
|
||||
try {
|
||||
const raw = await deps.transport.post<ApiEnvelope<AgnesTaskResponse>>(deps.transport.apiUrl("/videos"), payload, options);
|
||||
@@ -92,7 +94,7 @@ export function agnesPollUrl(config: ResolvedAiConfig, videoId: string, modelNam
|
||||
return base.toString();
|
||||
}
|
||||
|
||||
function buildAgnesV25Payload(config: ResolvedAiConfig, model: string, prompt: string, images: string[], videos: string[], audios: string[]) {
|
||||
function buildAgnesV25Payload(config: ResolvedAiConfig, model: string, prompt: string, images: string[], imageRoles: VideoImageRole[], videos: string[], audios: string[]) {
|
||||
if (!AGNES_V25_MODELS.has(model)) throw new Error(`Agnes 视频协议不支持模型 ${model}`);
|
||||
const seconds = agnesSeconds(config.videoSeconds);
|
||||
const size = agnesResolution(config.vquality);
|
||||
@@ -101,7 +103,10 @@ function buildAgnesV25Payload(config: ResolvedAiConfig, model: string, prompt: s
|
||||
if (images.length > 5) throw new Error("Agnes Video 2.5 Flash 最多支持 5 张参考图");
|
||||
if (videos.length) throw new Error("Agnes Video 2.5 Flash 不支持参考视频");
|
||||
}
|
||||
const mode = videos.length || audios.length || images.length > 2 ? "reference" : images.length ? "keyframe" : "text";
|
||||
const hasReferenceImages = imageRoles.includes("reference_image");
|
||||
const hasFrameImages = imageRoles.includes("first_frame") || imageRoles.includes("last_frame");
|
||||
if (hasFrameImages && (hasReferenceImages || videos.length || audios.length)) throw new Error("Agnes Video 2.5 不能同时混用首尾帧和角色、视频或音频参考素材");
|
||||
const mode = videos.length || audios.length || hasReferenceImages ? "reference" : images.length ? "keyframe" : "text";
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
prompt,
|
||||
@@ -112,8 +117,10 @@ function buildAgnesV25Payload(config: ResolvedAiConfig, model: string, prompt: s
|
||||
n: 1,
|
||||
};
|
||||
if (mode === "keyframe") {
|
||||
payload.first_frame = images[0];
|
||||
if (images[1]) payload.last_frame = images[1];
|
||||
const firstFrame = imageForRole(images, imageRoles, "first_frame") || images[0];
|
||||
const lastFrame = imageForRole(images, imageRoles, "last_frame");
|
||||
payload.first_frame = firstFrame;
|
||||
if (lastFrame) payload.last_frame = lastFrame;
|
||||
} else if (mode === "reference") {
|
||||
if (images.length) payload.images = images;
|
||||
if (audios.length) payload.audios = audios;
|
||||
@@ -122,8 +129,9 @@ function buildAgnesV25Payload(config: ResolvedAiConfig, model: string, prompt: s
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildAgnesV20Payload(config: ResolvedAiConfig, model: string, prompt: string, images: string[], videos: string[], audios: string[]) {
|
||||
function buildAgnesV20Payload(config: ResolvedAiConfig, model: string, prompt: string, images: string[], imageRoles: VideoImageRole[], videos: string[], audios: string[], options?: RequestOptions) {
|
||||
if (videos.length || audios.length) throw new Error("Agnes Video V2.0 不支持参考视频或音频");
|
||||
if (options?.videoEditOperation === "reference_to_video" || imageRoles.includes("reference_image")) throw new Error("Agnes Video V2.0 不支持角色或风格参考图模式,请改用 Agnes Video 2.5");
|
||||
const seconds = Math.max(1, Math.floor(Number(config.videoSeconds) || 5));
|
||||
const frameRate = 24;
|
||||
const targetFrames = seconds * frameRate;
|
||||
@@ -133,11 +141,17 @@ function buildAgnesV20Payload(config: ResolvedAiConfig, model: string, prompt: s
|
||||
frame_rate: frameRate,
|
||||
num_frames: Math.min(441, Math.floor((targetFrames - 1 + 4) / 8) * 8 + 1),
|
||||
};
|
||||
if (images.length === 1) payload.image = images[0];
|
||||
if (images.length > 1) payload.extra_body = { image: images, mode: "keyframes" };
|
||||
const orderedImages = [imageForRole(images, imageRoles, "first_frame"), imageForRole(images, imageRoles, "last_frame")].filter((value): value is string => Boolean(value));
|
||||
if (orderedImages.length === 1) payload.image = orderedImages[0];
|
||||
if (orderedImages.length > 1) payload.extra_body = { image: orderedImages, mode: "keyframes" };
|
||||
return payload;
|
||||
}
|
||||
|
||||
function imageForRole(images: string[], roles: VideoImageRole[], role: VideoImageRole) {
|
||||
const index = roles.indexOf(role);
|
||||
return index >= 0 ? images[index] : undefined;
|
||||
}
|
||||
|
||||
function agnesSeconds(value: string) {
|
||||
const seconds = Math.floor(Number(value) || 5);
|
||||
if (seconds < 4 || seconds > 12) throw new Error("Agnes Video 2.5 时长必须在 4–12 秒之间");
|
||||
|
||||
@@ -16,6 +16,7 @@ type GeminiVeoOperation = {
|
||||
|
||||
export async function createGeminiVeoTask(deps: VideoProviderDeps, config: ResolvedAiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions): Promise<VideoGenerationTask> {
|
||||
if (references.length > 1 || videoReferences.length || audioReferences.length) throw new Error("Gemini Veo 当前只支持 1 张起始图,不支持参考视频或音频");
|
||||
if (references.length && options?.videoEditOperation === "reference_to_video") throw new Error("Gemini Veo 当前不支持角色或风格参考图生视频,请改用支持 reference_to_video 的模型");
|
||||
const instance: Record<string, unknown> = { prompt: prompt.trim() };
|
||||
if (references[0]) {
|
||||
const dataUrl = await imageToDataUrl(references[0]);
|
||||
|
||||
@@ -9,22 +9,22 @@ import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
import type { MiniMaxVideoCreateResponse, MiniMaxVideoTask, RequestOptions, ResolvedAiConfig, VideoGenerationTask, VideoGenerationTaskState } from "./video-contracts";
|
||||
import type { VideoProviderDeps } from "./video-provider-deps";
|
||||
import { resolveVideoImageReferences } from "./video-reference-roles";
|
||||
|
||||
export async function createMiniMaxVideoTask(deps: VideoProviderDeps, config: ResolvedAiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions): Promise<VideoGenerationTask> {
|
||||
const imageUrls = await Promise.all(references.slice(0, 9).map((image) => resolveMiniMaxImageUrl(image)));
|
||||
const videoUrls = await Promise.all(videoReferences.slice(0, 3).map((video) => resolveMiniMaxMediaUrl(video, "参考视频")));
|
||||
const audioUrls = await Promise.all(audioReferences.slice(0, 3).map((audio) => resolveMiniMaxMediaUrl(audio, "参考音频")));
|
||||
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt.trim() }];
|
||||
const explicitFrameMode = options?.videoEditOperation !== "reference_to_video" && Boolean(options?.videoStartFrameNodeId || options?.videoEndFrameNodeId);
|
||||
const imagePlan = resolveVideoImageReferences(references.slice(0, 9), options, { videoCount: videoUrls.length, audioCount: audioUrls.length });
|
||||
const frameMode = imagePlan.some(({ role }) => role === "first_frame" || role === "last_frame");
|
||||
imageUrls.forEach((url, index) => {
|
||||
const reference = references[index];
|
||||
const role = miniMaxImageRole(reference?.id, options);
|
||||
if (explicitFrameMode && role === "reference_image") throw new Error("MiniMax 首尾帧模式不能混合未标记的参考图");
|
||||
const role = imagePlan[index]?.role || "reference_image";
|
||||
if (frameMode && role === "reference_image") throw new Error("MiniMax 首尾帧模式不能混合未标记的参考图");
|
||||
content.push({ type: "image_url", image_url: { url }, role });
|
||||
});
|
||||
videoUrls.forEach((url) => content.push({ type: "video_url", video_url: { url }, role: "reference_video" }));
|
||||
audioUrls.forEach((url) => content.push({ type: "audio_url", audio_url: { url }, role: "reference_audio" }));
|
||||
const frameMode = explicitFrameMode;
|
||||
const payload = {
|
||||
model: modelOptionName(model),
|
||||
content,
|
||||
@@ -43,13 +43,6 @@ export async function createMiniMaxVideoTask(deps: VideoProviderDeps, config: Re
|
||||
}
|
||||
}
|
||||
|
||||
function miniMaxImageRole(imageId: string | undefined, options?: RequestOptions) {
|
||||
if (options?.videoEditOperation === "reference_to_video") return "reference_image";
|
||||
if (imageId && imageId === options?.videoStartFrameNodeId) return "first_frame";
|
||||
if (imageId && imageId === options?.videoEndFrameNodeId) return "last_frame";
|
||||
return "reference_image";
|
||||
}
|
||||
|
||||
export async function pollMiniMaxVideoTask(deps: VideoProviderDeps, config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
|
||||
try {
|
||||
const response = await deps.transport.get<{ task?: MiniMaxVideoTask }>(miniMaxVideoUrl(config, `/query/video_generation/${encodeURIComponent(task.id)}`), options);
|
||||
|
||||
@@ -12,6 +12,7 @@ type NovitaVideoResult = { task?: { status?: string; reason?: string }; videos?:
|
||||
|
||||
export async function createNovitaVideoTask(deps: VideoProviderDeps, config: ResolvedAiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions): Promise<VideoGenerationTask> {
|
||||
if (references.length > 1 || videoReferences.length || audioReferences.length) throw new Error("Novita 视频当前只支持 1 张起始图,不支持参考视频或音频");
|
||||
if (references.length && options?.videoEditOperation === "reference_to_video") throw new Error("Novita 视频当前不支持角色或风格参考图生视频,请改用支持 reference_to_video 的模型");
|
||||
const payload: Record<string, unknown> = {
|
||||
model: modelOptionName(model),
|
||||
prompt: prompt.trim(),
|
||||
|
||||
@@ -13,13 +13,21 @@ export async function createOpenAIVideoTask(deps: VideoProviderDeps, config: Res
|
||||
if (config.interfaceType === "xai-video" || modelName.toLowerCase().includes("grok")) {
|
||||
const images = await Promise.all(references.slice(0, 7).map((image) => imageToDataUrl(image)));
|
||||
const seconds = normalizeVideoSeconds(config.videoSeconds);
|
||||
const referenceMode = options?.videoEditOperation === "reference_to_video";
|
||||
const explicitFrameMode = !referenceMode && Boolean(options?.videoStartFrameNodeId || options?.videoEndFrameNodeId);
|
||||
if (config.interfaceType === "xai-video" && explicitFrameMode && (images.length > 1 || options?.videoEndFrameNodeId)) throw new Error("xAI 视频协议最多支持 1 张起始图,不支持尾帧或混合角色参考图");
|
||||
const imagePayload = config.interfaceType === "xai-video"
|
||||
? referenceMode && images.length
|
||||
? { reference_images: images.map((url) => ({ url })) }
|
||||
: images.length ? { image: { url: images[0] } } : {}
|
||||
: images.length ? { image: images[0], images } : {};
|
||||
const payload = {
|
||||
model: modelName,
|
||||
prompt,
|
||||
duration: Number.parseInt(seconds, 10) || 6,
|
||||
seconds,
|
||||
...(normalizeVideoSize(config.size) ? { size: normalizeVideoSize(config.size) } : {}),
|
||||
...(images.length ? { image: images[0], images } : {}),
|
||||
...imagePayload,
|
||||
};
|
||||
try {
|
||||
const createPath = config.interfaceType === "xai-video" ? "/videos/generations" : "/videos";
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 config.interfaceType === "volcengine-ark-video" || isSeedanceVideoConfig(config);
|
||||
@@ -20,8 +21,8 @@ export async function createSeedanceTask(deps: VideoProviderDeps, config: Resolv
|
||||
assertSeedanceAudioReferences(audioReferences);
|
||||
const isVolcengineArk = config.interfaceType === "volcengine-ark-video";
|
||||
const payload = isVolcengineArk || isArkPlanBaseUrl(config.baseUrl)
|
||||
? await buildSeedanceAgentPlanPayload(config, model, prompt, references, videoReferences, audioReferences, deps)
|
||||
: await buildSeedanceVideosPayload(config, model, prompt, references, videoReferences, audioReferences, deps);
|
||||
? 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<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config), payload, options);
|
||||
@@ -80,13 +81,13 @@ function seedanceApiUrl(config: ResolvedAiConfig, taskId?: string) {
|
||||
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) {
|
||||
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(config.interfaceType === "volcengine-ark-video" ? "火山方舟全模态参考不支持纯音频或文本+音频,请同时添加参考图片或参考视频" : "Seedance 参考音频不能单独使用,请同时添加参考图或参考视频");
|
||||
}
|
||||
const content = config.interfaceType === "volcengine-ark-video"
|
||||
? await buildVolcengineArkContent(prompt, references, videoReferences, audioReferences)
|
||||
: await buildSeedanceContent(config, prompt, references, videoReferences, audioReferences, deps);
|
||||
? 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 {
|
||||
@@ -100,11 +101,12 @@ async function buildSeedanceAgentPlanPayload(config: ResolvedAiConfig, model: st
|
||||
};
|
||||
}
|
||||
|
||||
async function buildVolcengineArkContent(prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[]) {
|
||||
async function buildVolcengineArkContent(prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions) {
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
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 of references.slice(0, SEEDANCE_REFERENCE_LIMITS.images)) {
|
||||
content.push({ type: "image_url", image_url: { url: await resolveVolcengineArkReferenceUrl(image.url || image.dataUrl, image.storageKey) }, role: "reference_image" });
|
||||
for (const { image, role } of imagePlan) {
|
||||
content.push({ type: "image_url", image_url: { url: await resolveVolcengineArkReferenceUrl(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" });
|
||||
@@ -121,35 +123,45 @@ async function resolveVolcengineArkReferenceUrl(value: string | undefined, stora
|
||||
throw new Error("火山方舟视频参考素材需要公网 URL 或 asset:// 素材 ID;请先将本地素材保存到对象存储");
|
||||
}
|
||||
|
||||
async function buildSeedanceVideosPayload(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], deps: VideoProviderDeps) {
|
||||
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<string, unknown> = {};
|
||||
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) } : {}),
|
||||
...(imageUrls[0] ? { image_url: imageUrls[0] } : {}),
|
||||
...(imageUrls.length > 1 ? { reference_image_urls: imageUrls.slice(1) } : {}),
|
||||
...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) {
|
||||
async function buildSeedanceContent(config: AiConfig, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], deps: VideoProviderDeps, options?: RequestOptions) {
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
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 of references.slice(0, SEEDANCE_REFERENCE_LIMITS.images)) {
|
||||
content.push({ type: "image_url", image_url: { url: await resolveSeedanceImageUrl(image) }, role: "reference_image" });
|
||||
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" });
|
||||
@@ -160,6 +172,10 @@ async function buildSeedanceContent(config: AiConfig, prompt: string, references
|
||||
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;
|
||||
|
||||
45
web/src/services/api/video-reference-roles.ts
Normal file
45
web/src/services/api/video-reference-roles.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
|
||||
import type { RequestOptions } from "./video-contracts";
|
||||
|
||||
export type VideoImageRole = "first_frame" | "last_frame" | "reference_image";
|
||||
|
||||
export type ResolvedVideoImageReference = {
|
||||
image: ReferenceImage;
|
||||
role: VideoImageRole;
|
||||
};
|
||||
|
||||
type VideoReferenceContext = {
|
||||
videoCount?: number;
|
||||
audioCount?: number;
|
||||
};
|
||||
|
||||
export function resolveVideoImageReferences(images: ReferenceImage[], options?: RequestOptions, context: VideoReferenceContext = {}): ResolvedVideoImageReference[] {
|
||||
const operation = options?.videoEditOperation?.trim();
|
||||
if (operation === "reference_to_video") {
|
||||
return images.map((image) => ({ image, role: "reference_image" }));
|
||||
}
|
||||
|
||||
const startFrameId = options?.videoStartFrameNodeId?.trim();
|
||||
const endFrameId = options?.videoEndFrameNodeId?.trim();
|
||||
if (startFrameId || endFrameId) {
|
||||
return images.map((image) => ({
|
||||
image,
|
||||
role: image.id === startFrameId ? "first_frame" : image.id === endFrameId ? "last_frame" : "reference_image",
|
||||
}));
|
||||
}
|
||||
|
||||
if (context.videoCount || context.audioCount) {
|
||||
return images.map((image) => ({ image, role: "reference_image" }));
|
||||
}
|
||||
|
||||
// 传统创作页没有帧节点 ID:保留单图首帧、双图首尾帧的历史语义。
|
||||
return images.map((image, index) => ({
|
||||
image,
|
||||
role: index === 0 ? "first_frame" : index === 1 && images.length === 2 ? "last_frame" : "reference_image",
|
||||
}));
|
||||
}
|
||||
|
||||
export function hasExplicitVideoFrames(options?: RequestOptions) {
|
||||
return options?.videoEditOperation !== "reference_to_video" && Boolean(options?.videoStartFrameNodeId || options?.videoEndFrameNodeId);
|
||||
}
|
||||
Reference in New Issue
Block a user