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

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { defaultConfig } from "../src/stores/use-config-store";
import { createSeedanceTask } from "../src/services/api/video-provider-seedance";
import { createMiniMaxVideoTask } from "../src/services/api/video-provider-minimax";
import type { VideoProviderDeps } from "../src/services/api/video-provider-deps";
import { unwrapEnvelope, videoResponseTools, videoTaskId } from "../src/services/api/video-response";
import { isPublicMediaUrl, normalizeVideoResolution, normalizeVideoSeconds, normalizeVideoSize } from "../src/services/api/video-validation";
@@ -100,3 +101,37 @@ describe("Volcengine Ark full-modal references", () => {
await expect(createSeedanceTask(deps, config as never, model, "跟随节奏", [], [], [{ id: "audio-1", name: "audio.mp3", type: "audio/mpeg", url: "https://cdn.example.com/audio.mp3" }])).rejects.toThrow("不支持纯音频或文本+音频");
});
});
describe("MiniMax explicit reference roles", () => {
test("project assets use reference_image and reference_audio instead of first frame", async () => {
let requestBody: unknown;
const deps = {
transport: {
post: async (_url: string, body: unknown) => {
requestBody = body;
return { task_id: "minimax-task-1" };
},
},
response: videoResponseTools,
} as unknown as VideoProviderDeps;
const config = { ...defaultConfig, baseUrl: "https://api.minimaxi.com/v1", videoSeconds: "6", vquality: "768P", size: "16:9", videoWatermark: "false" };
await createMiniMaxVideoTask(
deps,
config as never,
"MiniMax-H3",
"保持角色一致",
[{ id: "character-1", name: "character.png", type: "image/png", dataUrl: "", url: "https://cdn.example.com/character.png" }],
[],
[{ id: "voice-1", name: "voice.mp3", type: "audio/mpeg", url: "https://cdn.example.com/voice.mp3" }],
{ videoEditOperation: "reference_to_video" },
);
expect((requestBody as { content: unknown[] }).content).toEqual([
{ type: "text", text: "保持角色一致" },
{ type: "image_url", image_url: { url: "https://cdn.example.com/character.png" }, role: "reference_image" },
{ type: "audio_url", audio_url: { url: "https://cdn.example.com/voice.mp3" }, role: "reference_audio" },
]);
expect((requestBody as { ratio: string }).ratio).toBe("16:9");
});
});

View File

@@ -0,0 +1,85 @@
import { expect, test } from "bun:test";
import { defaultConfig } from "../src/stores/use-config-store";
import { submitBackendGenerationTask, type GenerationTaskDependencies } from "../src/services/api/generation-task";
import type { GenerationTask } from "../src/services/api/task-center";
import type { ProjectDetail } from "../src/services/api/projects";
import { buildShotAssetReferenceContext, resolveShotAssetMentionPrompt } from "../src/pages/projects/detail/workflow-shot-references";
test("shot asset prompt keeps character names and maps character image, voice and scene assets", () => {
const detail = {
assets: [
{
id: "character-1",
title: "张天昊",
category: "character",
mediaType: "image",
primaryVersionId: "character-version-1",
character: {
versionId: "character-version-1",
definition: { voiceLanguage: "普通话", voiceAge: "青年男性", voiceTimbre: "略带疲惫和震惊" },
representations: [{ id: "representation-1", resourceId: "character-image-1", mediaType: "image/png", role: "primary" }],
voice: { profile: { sampleResourceId: "character-audio-1", language: "普通话", timbre: "沉稳" }, instructions: "内心独白语气" },
},
},
{
id: "scene-1",
title: "坑底场景",
category: "environment",
mediaType: "image",
primaryVersionId: "scene-version-1",
storageKey: "resource:scene-image-1",
},
],
shotReferences: [
{ shotId: "shot-1", assetVersionId: "character-version-1", status: "linked" },
{ shotId: "shot-1", assetVersionId: "scene-version-1", status: "linked" },
],
} as ProjectDetail;
const context = buildShotAssetReferenceContext(detail, "shot-1");
const prompt = resolveShotAssetMentionPrompt("张天昊在 @[asset:scene-1] 睁开眼睛", context);
expect(context.referenceImages).toHaveLength(2);
expect(context.referenceAudios).toHaveLength(1);
expect(context.referenceAudios[0]?.storageKey).toBe("resource:character-audio-1");
expect(context.resolvedCharacterVersions).toEqual([{ assetId: "character-1", versionId: "character-version-1" }]);
expect(prompt).toContain("张天昊在 图片2 睁开眼睛");
expect(prompt).toContain("- 张天昊:人物参考:图片1;声音参考:音频1");
expect(prompt).toContain("声音说明:普通话;青年男性;略带疲惫和震惊;内心独白语气");
expect(prompt).toContain("- 坑底场景:场景参考:图片2");
});
test("background generation submission returns after task creation without waiting", async () => {
let waitCalls = 0;
const task = {
id: "task-1",
type: "canvas_video",
status: "queued",
prompt: "角色表演",
attempts: 0,
createdAt: "2026-08-30T00:00:00.000Z",
updatedAt: "2026-08-30T00:00:00.000Z",
} satisfies GenerationTask;
const dependencies: GenerationTaskDependencies = {
createTask: async () => task,
waitTask: async () => {
waitCalls += 1;
throw new Error("should not wait");
},
runLocal: async () => ({ mode: "video" }),
createId: () => "id-1",
now: () => "2026-08-30T00:00:00.000Z",
};
const submitted = await submitBackendGenerationTask({
projectId: "project-1",
mode: "video",
prompt: "角色表演",
config: { ...defaultConfig, model: "MiniMax-H3", videoModel: "MiniMax-H3" },
metadata: { shotId: "shot-1", videoEditOperation: "reference_to_video" },
}, dependencies);
expect(submitted).toBe(task);
expect(waitCalls).toBe(0);
});