fix(canvas): 短剧分镜 - 支持多镜头并行生成并正确引用角色图声资产

This commit is contained in:
ddCat
2026-08-30 16:30:51 +08:00
parent f62ea62355
commit 4787c130d0
13 changed files with 393 additions and 55 deletions

View File

@@ -124,6 +124,20 @@ export async function runBackendGenerationTask(
return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, signal, metadata, onTaskUpdate }, prepared, dependencies);
}
// 分镜等后台生产流程只需要可靠提交任务;任务状态与产物由项目工作区轮询和
// 后端自动回填负责,不能让页面 mutation 一直等待供应商完成。
export async function submitBackendGenerationTask(
options: BackendGenerationTaskOptions,
dependencies: GenerationTaskDependencies = defaultDependencies,
): Promise<GenerationTask> {
throwIfAborted(options.signal);
assertClientPromptLimit(options.mode, options.prompt, options.config, options.metadata);
if (usesLocalDreamina(options.config)) throw new Error("本机即梦任务暂不支持后台提交");
const prepared = await prepareGenerationReferences(options);
throwIfAborted(options.signal);
return createBackendGenerationTask(options, prepared, dependencies);
}
export async function runBackendToolGenerationTask(options: {
prompt: string;
config: AiConfig;
@@ -401,7 +415,14 @@ async function prepareGenerationReferences({
}
async function createAndWaitGenerationTask(options: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences, dependencies: GenerationTaskDependencies) {
const { projectId, mode, prompt, config, signal, metadata, onTaskUpdate, onTextDelta } = options;
const task = await createBackendGenerationTask(options, prepared, dependencies);
const { signal, onTaskUpdate, onTextDelta } = options;
const completed = await dependencies.waitTask(task.id, { signal, initialTask: task, onTaskUpdate, onTextDelta });
return parseBackendGenerationResult(completed);
}
async function createBackendGenerationTask(options: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences, dependencies: GenerationTaskDependencies) {
const { projectId, mode, prompt, config, metadata, onTaskUpdate } = options;
const videoOperation = generationOperation(options);
const workflow = resolveGenerationWorkflowExecution(config, mode);
const logicalModelId = workflow ? "" : logicalModelIDForConfig(config);
@@ -428,8 +449,7 @@ async function createAndWaitGenerationTask(options: BackendGenerationTaskOptions
},
});
onTaskUpdate?.(task);
const completed = await dependencies.waitTask(task.id, { signal, initialTask: task, onTaskUpdate, onTextDelta });
return parseBackendGenerationResult(completed);
return task;
}
async function prepareBackendMediaReference(media: ReferenceVideo | ReferenceAudio) {

View File

@@ -38,7 +38,12 @@ export type SeedanceTask = {
};
export type ApiEnvelope<T> = T | { code?: number; data?: T | null; msg?: string };
export type RequestOptions = { signal?: AbortSignal };
export type RequestOptions = {
signal?: AbortSignal;
videoEditOperation?: string;
videoStartFrameNodeId?: string;
videoEndFrameNodeId?: string;
};
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
export type VideoGenerationTask = { id: string; provider: "openai" | "agnes" | "seedance" | "video-generations" | "gemini-veo" | "novita" | "minimax"; model: string };

View File

@@ -15,14 +15,16 @@ export async function createMiniMaxVideoTask(deps: VideoProviderDeps, config: Re
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);
imageUrls.forEach((url, index) => {
// 首尾帧只允许成对出现;三张及以上图片统一作为多模态参考图,避免提交非法组合。
const role = videoUrls.length || audioUrls.length || imageUrls.length > 2 ? "reference_image" : index === 0 ? "first_frame" : "last_frame";
const reference = references[index];
const role = miniMaxImageRole(reference?.id, options);
if (explicitFrameMode && 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 = imageUrls.length > 0 && imageUrls.length <= 2 && videoUrls.length === 0 && audioUrls.length === 0;
const frameMode = explicitFrameMode;
const payload = {
model: modelOptionName(model),
content,
@@ -41,6 +43,13 @@ 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);