You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
722 lines
22 KiB
722 lines
22 KiB
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { executeGenerateVideo } from "../generateVideoExecutor";
|
|
import type { NodeExecutionContext } from "../types";
|
|
import type { WorkflowNode } from "@/types";
|
|
import {
|
|
POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO,
|
|
POPI_VIDEO_SUBTYPE_OMNI_REFERENCE,
|
|
} from "@/constants/generationTask";
|
|
|
|
const mockFetch = vi.fn();
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const defaultProviderSettings = {
|
|
providers: {
|
|
gemini: { apiKey: "" },
|
|
replicate: { apiKey: "" },
|
|
fal: { apiKey: "fal-key" },
|
|
kie: { apiKey: "" },
|
|
wavespeed: { apiKey: "" },
|
|
openai: { apiKey: "" },
|
|
newapiwg: { apiKey: "" },
|
|
},
|
|
} as any;
|
|
|
|
function makeNode(data: Record<string, unknown> = {}): WorkflowNode {
|
|
return {
|
|
id: "vid-1",
|
|
type: "generateVideo",
|
|
position: { x: 0, y: 0 },
|
|
data: {
|
|
outputVideo: null,
|
|
inputImages: [],
|
|
inputPrompt: null,
|
|
status: null,
|
|
error: null,
|
|
selectedModel: { provider: "fal", modelId: "fal-video-model", displayName: "Fal Video" },
|
|
parameters: {},
|
|
videoHistory: [],
|
|
selectedVideoHistoryIndex: 0,
|
|
...data,
|
|
},
|
|
} as WorkflowNode;
|
|
}
|
|
|
|
function makeCtx(
|
|
node: WorkflowNode,
|
|
overrides: Partial<NodeExecutionContext> = {}
|
|
): NodeExecutionContext {
|
|
return {
|
|
node,
|
|
getConnectedInputs: vi.fn().mockReturnValue({
|
|
images: [],
|
|
videos: [],
|
|
audio: [],
|
|
text: "video prompt",
|
|
dynamicInputs: {},
|
|
easeCurve: null,
|
|
}),
|
|
updateNodeData: vi.fn(),
|
|
getFreshNode: vi.fn().mockReturnValue(node),
|
|
getEdges: vi.fn().mockReturnValue([]),
|
|
getNodes: vi.fn().mockReturnValue([node]),
|
|
providerSettings: defaultProviderSettings,
|
|
addIncurredCost: vi.fn(),
|
|
appendOutputGalleryImage: vi.fn(),
|
|
get: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function mockPopiTaskSuccess() {
|
|
mockFetch.mockImplementation(async (url: string) => {
|
|
if (url === "/api/generate/task/create") {
|
|
return {
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, taskId: "task-1" }),
|
|
};
|
|
}
|
|
if (url.startsWith("/api/generate/task/poll")) {
|
|
return {
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
status: "completed",
|
|
result: { success: true, video: "data:video/mp4;base64,output" },
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
|
});
|
|
}
|
|
|
|
async function withProviderMode<T>(mode: "multi" | "popi", run: () => Promise<T>): Promise<T> {
|
|
const originalNextPublicMode = process.env.NEXT_PUBLIC_PROVIDER_MODE;
|
|
const originalProviderMode = process.env.PROVIDER_MODE;
|
|
|
|
process.env.NEXT_PUBLIC_PROVIDER_MODE = mode;
|
|
process.env.PROVIDER_MODE = mode;
|
|
try {
|
|
return await run();
|
|
} finally {
|
|
if (originalNextPublicMode === undefined) {
|
|
delete process.env.NEXT_PUBLIC_PROVIDER_MODE;
|
|
} else {
|
|
process.env.NEXT_PUBLIC_PROVIDER_MODE = originalNextPublicMode;
|
|
}
|
|
if (originalProviderMode === undefined) {
|
|
delete process.env.PROVIDER_MODE;
|
|
} else {
|
|
process.env.PROVIDER_MODE = originalProviderMode;
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("executeGenerateVideo", () => {
|
|
it("submits the task when inputs are empty", async () => {
|
|
mockPopiTaskSuccess();
|
|
const node = makeNode();
|
|
const ctx = makeCtx(node, {
|
|
getConnectedInputs: vi.fn().mockReturnValue({
|
|
images: [],
|
|
videos: [],
|
|
audio: [],
|
|
text: null,
|
|
dynamicInputs: {},
|
|
easeCurve: null,
|
|
}),
|
|
});
|
|
|
|
await executeGenerateVideo(ctx);
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"/api/generate/task/create",
|
|
expect.objectContaining({ method: "POST" })
|
|
);
|
|
});
|
|
|
|
it("should throw when no model selected", async () => {
|
|
await withProviderMode("multi", async () => {
|
|
const node = makeNode({ selectedModel: null });
|
|
const ctx = makeCtx(node);
|
|
|
|
await expect(executeGenerateVideo(ctx)).rejects.toThrow("No model selected");
|
|
});
|
|
});
|
|
|
|
it("should set loading status before API call", async () => {
|
|
const node = makeNode();
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node);
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
|
|
const loadingCall = calls.find(
|
|
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "loading"
|
|
);
|
|
expect(loadingCall).toBeDefined();
|
|
});
|
|
|
|
it("should call /api/generate with mediaType=video", async () => {
|
|
const node = makeNode();
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node);
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.mediaType).toBe("video");
|
|
expect(body.prompt).toBe("video prompt");
|
|
});
|
|
|
|
it("joins multiple connected text inputs with the local prompt without storing the joined text", async () => {
|
|
const node = makeNode({ inputPrompt: "local prompt" });
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node, {
|
|
getConnectedInputs: vi.fn().mockReturnValue({
|
|
images: [],
|
|
videos: [],
|
|
audio: [],
|
|
text: "first text",
|
|
textItems: ["first text", "second text"],
|
|
batchTextItems: [],
|
|
dynamicInputs: {},
|
|
easeCurve: null,
|
|
}),
|
|
});
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.prompt).toBe("first text,second text,local prompt");
|
|
expect(ctx.updateNodeData).toHaveBeenCalledWith("vid-1", expect.objectContaining({
|
|
inputPrompt: "local prompt",
|
|
}));
|
|
});
|
|
|
|
it("should allow connected video input without prompt", async () => {
|
|
const node = makeNode();
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node, {
|
|
getConnectedInputs: vi.fn().mockReturnValue({
|
|
images: [],
|
|
videos: ["data:video/mp4;base64,input"],
|
|
audio: [],
|
|
text: null,
|
|
dynamicInputs: {},
|
|
easeCurve: null,
|
|
}),
|
|
});
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.dynamicInputs).toEqual({
|
|
video: "data:video/mp4;base64,input",
|
|
videos: ["data:video/mp4;base64,input"],
|
|
});
|
|
});
|
|
|
|
it("passes connected audio inputs as voices for video generation", async () => {
|
|
const node = makeNode();
|
|
mockFetch.mockImplementation(async (url: string) => {
|
|
if (url === "/api/generate/task/create") {
|
|
return {
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, taskId: "task-1" }),
|
|
};
|
|
}
|
|
if (url.startsWith("/api/generate/task/poll")) {
|
|
return {
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
status: "completed",
|
|
result: { success: true, video: "data:video/mp4;base64,output" },
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
|
});
|
|
|
|
const ctx = makeCtx(node, {
|
|
getConnectedInputs: vi.fn().mockReturnValue({
|
|
images: [],
|
|
videos: ["https://cdn.example.com/unsupported-video.mp4"],
|
|
audio: ["https://cdn.example.com/input-audio.mp3"],
|
|
text: null,
|
|
dynamicInputs: {
|
|
videos: ["https://cdn.example.com/legacy-video.mp4"],
|
|
video_url: "https://cdn.example.com/legacy-video-url.mp4",
|
|
audios: ["https://cdn.example.com/legacy-audio.mp3"],
|
|
audio_url: "https://cdn.example.com/legacy-audio-url.mp3",
|
|
motion: "cinematic",
|
|
},
|
|
easeCurve: null,
|
|
}),
|
|
});
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.dynamicInputs).toEqual({
|
|
voices: ["https://cdn.example.com/input-audio.mp3"],
|
|
motion: "cinematic",
|
|
});
|
|
});
|
|
|
|
it("should preserve schema-mapped video dynamic input", async () => {
|
|
const node = makeNode();
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node, {
|
|
getConnectedInputs: vi.fn().mockReturnValue({
|
|
images: [],
|
|
videos: ["data:video/mp4;base64,input"],
|
|
audio: [],
|
|
text: null,
|
|
dynamicInputs: { video_urls: "data:video/mp4;base64,input" },
|
|
easeCurve: null,
|
|
}),
|
|
});
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.dynamicInputs).toEqual({ video_urls: "data:video/mp4;base64,input" });
|
|
});
|
|
|
|
it("uploads large NewApiWG video inputs before sending the generate request", async () => {
|
|
const largeVideo = `data:video/mp4;base64,${"a".repeat(400_000)}`;
|
|
const node = makeNode({
|
|
selectedModel: {
|
|
provider: "newapiwg",
|
|
modelId: "doubao-seedance-2-0-260128",
|
|
displayName: "Seedance",
|
|
capabilities: ["text-to-video", "image-to-video"],
|
|
},
|
|
});
|
|
const ctx = makeCtx(node, {
|
|
getConnectedInputs: vi.fn().mockReturnValue({
|
|
images: [],
|
|
videos: [largeVideo],
|
|
audio: [],
|
|
text: null,
|
|
dynamicInputs: {},
|
|
easeCurve: null,
|
|
}),
|
|
});
|
|
|
|
mockFetch.mockImplementation(async (url: string) => {
|
|
if (url === "/api/images/upload") {
|
|
return {
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
id: "tmp-video-1",
|
|
url: "http://localhost:3000/api/images/tmp-video-1",
|
|
}),
|
|
};
|
|
}
|
|
if (url === "/api/generate") {
|
|
return {
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.example.com/out.mp4" }),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
|
});
|
|
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const generateCall = mockFetch.mock.calls.find(([url]) => url === "/api/generate");
|
|
const cleanupCall = mockFetch.mock.calls.find(
|
|
([url, init]) => url === "/api/images/upload" && init?.method === "DELETE"
|
|
);
|
|
const body = JSON.parse(generateCall?.[1].body);
|
|
|
|
expect(body.dynamicInputs).toEqual({
|
|
video: "http://localhost:3000/api/images/tmp-video-1",
|
|
videos: ["http://localhost:3000/api/images/tmp-video-1"],
|
|
});
|
|
expect(JSON.stringify(body)).not.toContain("base64");
|
|
expect(cleanupCall?.[1].body).toBe(JSON.stringify({ ids: ["tmp-video-1"] }));
|
|
});
|
|
|
|
it("passes stored video parameters without filling legacy fields", async () => {
|
|
const node = makeNode({
|
|
selectedModel: { provider: "popiserver", modelId: "bytedance/seedance-2/text-to-video", displayName: "Seedance" },
|
|
aspectRatio: "16:9",
|
|
resolution: "1080p",
|
|
durationSeconds: 8,
|
|
fps: 24,
|
|
soundEnabled: true,
|
|
parameters: {
|
|
motion: "cinematic",
|
|
duration: 4,
|
|
},
|
|
});
|
|
mockPopiTaskSuccess();
|
|
|
|
const ctx = makeCtx(node);
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.parameters).toEqual({
|
|
motion: "cinematic",
|
|
duration: 4,
|
|
});
|
|
expect(body.subType).toBeUndefined();
|
|
});
|
|
|
|
it("keeps stored PopiServer schema parameters instead of overriding from legacy fields", async () => {
|
|
const node = makeNode({
|
|
selectedModel: { provider: "popiserver", modelId: "37", displayName: "Ernie Image Turbo" },
|
|
resolution: "1080p",
|
|
durationSeconds: 8,
|
|
parameters: {
|
|
ratio: "9:16",
|
|
resolution: "1024",
|
|
duration: 4,
|
|
videoRatio: 10,
|
|
},
|
|
});
|
|
mockPopiTaskSuccess();
|
|
|
|
const ctx = makeCtx(node);
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.parameters).toEqual({
|
|
ratio: "9:16",
|
|
resolution: "1024",
|
|
duration: 4,
|
|
videoRatio: 10,
|
|
});
|
|
expect(body.subType).toBeUndefined();
|
|
});
|
|
|
|
it("passes persisted PopiServer subtype through without executor-side normalization", async () => {
|
|
const node = makeNode({
|
|
selectedModel: { provider: "popiserver", modelId: "44", displayName: "Seedance" },
|
|
subType: POPI_VIDEO_SUBTYPE_OMNI_REFERENCE,
|
|
parameters: {
|
|
duration: 4,
|
|
},
|
|
});
|
|
mockPopiTaskSuccess();
|
|
|
|
const ctx = makeCtx(node, {
|
|
getConnectedInputs: vi.fn().mockReturnValue({
|
|
images: [
|
|
"https://static.popi.art/1.png",
|
|
"https://static.popi.art/2.png",
|
|
"https://static.popi.art/3.png",
|
|
],
|
|
videos: [],
|
|
audio: [],
|
|
text: "video prompt",
|
|
dynamicInputs: {},
|
|
easeCurve: null,
|
|
}),
|
|
});
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.parameters?.subType).toBeUndefined();
|
|
expect(body.subType).toBe(POPI_VIDEO_SUBTYPE_OMNI_REFERENCE);
|
|
});
|
|
|
|
it("uses persisted config over legacy video fields when executing", async () => {
|
|
const node = makeNode({
|
|
selectedModel: { provider: "kie", modelId: "legacy-video", displayName: "Legacy Video" },
|
|
inputPrompt: "legacy prompt",
|
|
aspectRatio: "1:1",
|
|
resolution: "720p",
|
|
durationSeconds: 4,
|
|
parameters: {
|
|
motion: "legacy",
|
|
resolution: "720p",
|
|
duration: 4,
|
|
},
|
|
config: {
|
|
prompt: "config prompt",
|
|
aspectRatio: "9:16",
|
|
resolution: "1080p",
|
|
durationSeconds: 8,
|
|
selectedModel: {
|
|
provider: "kie",
|
|
modelId: "bytedance/seedance-2/text-to-video",
|
|
displayName: "Seedance",
|
|
capabilities: ["text-to-video"],
|
|
},
|
|
inputImages: [],
|
|
batchSize: 1,
|
|
soundEnabled: true,
|
|
parameters: {
|
|
motion: "config",
|
|
},
|
|
},
|
|
});
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node, {
|
|
getConnectedInputs: vi.fn().mockReturnValue({
|
|
images: [],
|
|
videos: [],
|
|
audio: [],
|
|
text: null,
|
|
dynamicInputs: {},
|
|
easeCurve: null,
|
|
}),
|
|
});
|
|
await executeGenerateVideo(ctx, { useStoredFallback: true });
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.prompt).toBe("config prompt");
|
|
expect(body.selectedModel).toMatchObject({ modelId: "bytedance/seedance-2/text-to-video" });
|
|
expect(body.parameters).toEqual({
|
|
motion: "config",
|
|
aspectRatio: "9:16",
|
|
aspect_ratio: "9:16",
|
|
resolution: "1080p",
|
|
duration: 8,
|
|
generate_audio: true,
|
|
});
|
|
});
|
|
|
|
it("submits video durations without local validation", async () => {
|
|
mockPopiTaskSuccess();
|
|
const node = makeNode({ durationSeconds: 3 });
|
|
const ctx = makeCtx(node);
|
|
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.parameters).toMatchObject({ duration: 3 });
|
|
});
|
|
|
|
it("uses the selected video model even when legacy fallback fields are present", async () => {
|
|
const node = makeNode({
|
|
selectedModel: {
|
|
provider: "newapiwg",
|
|
modelId: "viduq3-turbo",
|
|
displayName: "Vidu Q3 Turbo",
|
|
},
|
|
});
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.example.com/video.mp4" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node);
|
|
await executeGenerateVideo(ctx);
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.selectedModel).toMatchObject({
|
|
provider: "newapiwg",
|
|
modelId: "viduq3-turbo",
|
|
});
|
|
});
|
|
|
|
it("should update node with video result on success", async () => {
|
|
const node = makeNode();
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node);
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
|
|
const completeCall = calls.find(
|
|
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete"
|
|
);
|
|
expect(completeCall).toBeDefined();
|
|
expect((completeCall![1] as Record<string, unknown>).outputVideo).toBe("data:video/mp4;base64,output");
|
|
});
|
|
|
|
it("should handle videoUrl field in response", async () => {
|
|
const node = makeNode();
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.fal.media/video.mp4" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node);
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
|
|
const completeCall = calls.find(
|
|
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete"
|
|
);
|
|
expect((completeCall![1] as Record<string, unknown>).outputVideo).toBe("https://cdn.fal.media/video.mp4");
|
|
expect((completeCall![1] as Record<string, unknown>).outputVideoRemoteUrl).toBe("https://cdn.fal.media/video.mp4");
|
|
expect((completeCall![1] as Record<string, unknown>).outputVideoStorageStatus).toBe("remote-only");
|
|
});
|
|
|
|
it("should handle image fallback response", async () => {
|
|
const node = makeNode();
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, image: "data:image/png;base64,preview" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node);
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
|
|
const completeCall = calls.find(
|
|
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete"
|
|
);
|
|
expect((completeCall![1] as Record<string, unknown>).outputVideo).toBe("data:image/png;base64,preview");
|
|
});
|
|
|
|
it("should track cost for fal provider", async () => {
|
|
const node = makeNode({
|
|
selectedModel: { provider: "fal", modelId: "fal-vid", displayName: "Fal", pricing: { amount: 0.25 } },
|
|
});
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,out" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node, {
|
|
getFreshNode: vi.fn().mockReturnValue(node),
|
|
});
|
|
await executeGenerateVideo(ctx);
|
|
|
|
expect(ctx.addIncurredCost).toHaveBeenCalledWith(0.25);
|
|
});
|
|
|
|
it("should throw on HTTP error", async () => {
|
|
const node = makeNode();
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
statusText: "Internal Server Error",
|
|
text: () => Promise.resolve('{"error": "Video gen failed"}'),
|
|
});
|
|
|
|
const ctx = makeCtx(node);
|
|
await expect(executeGenerateVideo(ctx)).rejects.toThrow("Video gen failed");
|
|
});
|
|
|
|
it("should throw on API failure", async () => {
|
|
const node = makeNode();
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: false, error: "Bad video" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node);
|
|
await expect(executeGenerateVideo(ctx)).rejects.toThrow("Bad video");
|
|
});
|
|
|
|
it("should use stored fallback in regenerate mode", async () => {
|
|
const node = makeNode({
|
|
inputImages: ["stored-img.png"],
|
|
inputPrompt: "stored video prompt",
|
|
});
|
|
const ctx = makeCtx(node, {
|
|
getConnectedInputs: vi.fn().mockReturnValue({
|
|
images: [],
|
|
videos: [],
|
|
audio: [],
|
|
text: null,
|
|
dynamicInputs: {},
|
|
easeCurve: null,
|
|
}),
|
|
getFreshNode: vi.fn().mockReturnValue(node),
|
|
});
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,out" }),
|
|
});
|
|
|
|
await executeGenerateVideo(ctx, { useStoredFallback: true });
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.images).toEqual(["stored-img.png"]);
|
|
expect(body.prompt).toBe("stored video prompt");
|
|
});
|
|
|
|
it("should add to video history with 50-item limit", async () => {
|
|
const existingHistory = Array.from({ length: 50 }, (_, i) => ({
|
|
id: `old-${i}`,
|
|
timestamp: i,
|
|
prompt: `old-${i}`,
|
|
model: "m",
|
|
}));
|
|
const node = makeNode({ videoHistory: existingHistory });
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,out" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node, {
|
|
getFreshNode: vi.fn().mockReturnValue(node),
|
|
});
|
|
await executeGenerateVideo(ctx);
|
|
|
|
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
|
|
const completeCall = calls.find(
|
|
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete"
|
|
);
|
|
const videoHistory = (completeCall![1] as Record<string, unknown>).videoHistory as unknown[];
|
|
expect(videoHistory.length).toBe(50); // capped at 50
|
|
expect(videoHistory[0]).toMatchObject({
|
|
video: "data:video/mp4;base64,out",
|
|
model: "fal-video-model",
|
|
modelDisplayName: "Fal Video",
|
|
modelProvider: "fal",
|
|
});
|
|
expect(completeCall![1]).toMatchObject({
|
|
lastUsedModel: {
|
|
provider: "fal",
|
|
modelId: "fal-video-model",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not retry with another model when primary video generation fails", async () => {
|
|
const node = makeNode({
|
|
selectedModel: {
|
|
provider: "replicate",
|
|
modelId: "primary-video",
|
|
displayName: "Primary Video",
|
|
},
|
|
});
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: false, error: "Primary video boom" }),
|
|
});
|
|
|
|
const ctx = makeCtx(node);
|
|
await expect(executeGenerateVideo(ctx)).rejects.toThrow("Primary video boom");
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|