2 changed files with 469 additions and 0 deletions
@ -0,0 +1,274 @@ |
|||
import { describe, it, expect, vi, beforeEach } from "vitest"; |
|||
import { executeGenerateVideo } from "../generateVideoExecutor"; |
|||
import type { NodeExecutionContext } from "../types"; |
|||
import type { WorkflowNode } from "@/types"; |
|||
|
|||
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: "" }, |
|||
}, |
|||
} 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(), |
|||
addToGlobalHistory: vi.fn(), |
|||
generationsPath: null, |
|||
saveDirectoryPath: null, |
|||
get: vi.fn(), |
|||
...overrides, |
|||
}; |
|||
} |
|||
|
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
describe("executeGenerateVideo", () => { |
|||
it("should throw when missing required inputs", async () => { |
|||
const node = makeNode(); |
|||
const ctx = makeCtx(node, { |
|||
getConnectedInputs: vi.fn().mockReturnValue({ |
|||
images: [], |
|||
videos: [], |
|||
audio: [], |
|||
text: null, |
|||
dynamicInputs: {}, |
|||
easeCurve: null, |
|||
}), |
|||
}); |
|||
|
|||
await expect(executeGenerateVideo(ctx)).rejects.toThrow("Missing required inputs"); |
|||
}); |
|||
|
|||
it("should throw when no model selected", 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("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"); |
|||
}); |
|||
|
|||
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
|
|||
}); |
|||
}); |
|||
@ -0,0 +1,195 @@ |
|||
/** |
|||
* GenerateVideo Executor |
|||
* |
|||
* Unified executor for generateVideo nodes. |
|||
* Used by both executeWorkflow and regenerateNode. |
|||
*/ |
|||
|
|||
import type { GenerateVideoNodeData } from "@/types"; |
|||
import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders"; |
|||
import type { NodeExecutionContext } from "./types"; |
|||
|
|||
export interface GenerateVideoOptions { |
|||
/** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */ |
|||
useStoredFallback?: boolean; |
|||
} |
|||
|
|||
export async function executeGenerateVideo( |
|||
ctx: NodeExecutionContext, |
|||
options: GenerateVideoOptions = {} |
|||
): Promise<void> { |
|||
const { |
|||
node, |
|||
getConnectedInputs, |
|||
updateNodeData, |
|||
getFreshNode, |
|||
signal, |
|||
providerSettings, |
|||
addIncurredCost, |
|||
generationsPath, |
|||
getNodes, |
|||
} = ctx; |
|||
|
|||
const { useStoredFallback = false } = options; |
|||
|
|||
const { images: connectedImages, text: connectedText, dynamicInputs } = getConnectedInputs(node.id); |
|||
|
|||
// Get fresh node data from store
|
|||
const freshNode = getFreshNode(node.id); |
|||
const nodeData = (freshNode?.data || node.data) as GenerateVideoNodeData; |
|||
|
|||
// Determine images and text
|
|||
let images: string[]; |
|||
let text: string | null; |
|||
|
|||
if (useStoredFallback) { |
|||
images = connectedImages.length > 0 ? connectedImages : nodeData.inputImages; |
|||
text = connectedText ?? nodeData.inputPrompt; |
|||
} else { |
|||
images = connectedImages; |
|||
text = connectedText; |
|||
// For dynamic inputs, check if we have at least a prompt
|
|||
const hasPrompt = text || dynamicInputs.prompt || dynamicInputs.negative_prompt; |
|||
if (!hasPrompt && images.length === 0) { |
|||
updateNodeData(node.id, { |
|||
status: "error", |
|||
error: "Missing required inputs", |
|||
}); |
|||
throw new Error("Missing required inputs"); |
|||
} |
|||
} |
|||
|
|||
if (useStoredFallback && !text) { |
|||
updateNodeData(node.id, { |
|||
status: "error", |
|||
error: "Missing text input", |
|||
}); |
|||
throw new Error("Missing text input"); |
|||
} |
|||
|
|||
if (!nodeData.selectedModel?.modelId) { |
|||
updateNodeData(node.id, { |
|||
status: "error", |
|||
error: "No model selected", |
|||
}); |
|||
throw new Error("No model selected"); |
|||
} |
|||
|
|||
updateNodeData(node.id, { |
|||
inputImages: images, |
|||
inputPrompt: text, |
|||
status: "loading", |
|||
error: null, |
|||
}); |
|||
|
|||
const provider = nodeData.selectedModel.provider; |
|||
const headers = buildGenerateHeaders(provider, providerSettings); |
|||
|
|||
const requestPayload = { |
|||
images, |
|||
prompt: text, |
|||
selectedModel: nodeData.selectedModel, |
|||
parameters: nodeData.parameters, |
|||
dynamicInputs, |
|||
mediaType: "video" as const, |
|||
}; |
|||
|
|||
const response = await fetch("/api/generate", { |
|||
method: "POST", |
|||
headers, |
|||
body: JSON.stringify(requestPayload), |
|||
...(signal ? { signal } : {}), |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
const errorText = await response.text(); |
|||
let errorMessage = `HTTP ${response.status}`; |
|||
try { |
|||
const errorJson = JSON.parse(errorText); |
|||
errorMessage = errorJson.error || errorMessage; |
|||
} catch { |
|||
if (errorText) errorMessage += ` - ${errorText.substring(0, 200)}`; |
|||
} |
|||
|
|||
updateNodeData(node.id, { |
|||
status: "error", |
|||
error: errorMessage, |
|||
}); |
|||
throw new Error(errorMessage); |
|||
} |
|||
|
|||
const result = await response.json(); |
|||
|
|||
// Handle video response (video or videoUrl field)
|
|||
const videoData = result.video || result.videoUrl; |
|||
if (result.success && (videoData || result.image)) { |
|||
const outputContent = videoData || result.image; |
|||
const timestamp = Date.now(); |
|||
const videoId = `${timestamp}`; |
|||
|
|||
// Add to node's video history
|
|||
const newHistoryItem = { |
|||
id: videoId, |
|||
timestamp, |
|||
prompt: text || "", |
|||
model: nodeData.selectedModel?.modelId || "", |
|||
}; |
|||
const updatedHistory = [newHistoryItem, ...(nodeData.videoHistory || [])].slice(0, 50); |
|||
|
|||
updateNodeData(node.id, { |
|||
outputVideo: outputContent, |
|||
status: "complete", |
|||
error: null, |
|||
videoHistory: updatedHistory, |
|||
selectedVideoHistoryIndex: 0, |
|||
}); |
|||
|
|||
// Track cost
|
|||
if (nodeData.selectedModel?.provider === "fal" && nodeData.selectedModel?.pricing) { |
|||
addIncurredCost(nodeData.selectedModel.pricing.amount); |
|||
} |
|||
|
|||
// Auto-save to generations folder if configured
|
|||
if (generationsPath) { |
|||
const saveContent = videoData |
|||
? { video: videoData } |
|||
: { image: result.image }; |
|||
const historyType = videoData ? "video" : "video"; |
|||
|
|||
fetch("/api/save-generation", { |
|||
method: "POST", |
|||
headers: { "Content-Type": "application/json" }, |
|||
body: JSON.stringify({ |
|||
directoryPath: generationsPath, |
|||
...saveContent, |
|||
prompt: text, |
|||
imageId: videoId, |
|||
}), |
|||
}) |
|||
.then((res) => res.json()) |
|||
.then((saveResult) => { |
|||
if (saveResult.success && saveResult.imageId && saveResult.imageId !== videoId) { |
|||
const currentNode = getNodes().find((n) => n.id === node.id); |
|||
if (currentNode) { |
|||
const currentData = currentNode.data as GenerateVideoNodeData; |
|||
const histCopy = [...(currentData.videoHistory || [])]; |
|||
const entryIndex = histCopy.findIndex((h) => h.id === videoId); |
|||
if (entryIndex !== -1) { |
|||
histCopy[entryIndex] = { ...histCopy[entryIndex], id: saveResult.imageId }; |
|||
updateNodeData(node.id, { videoHistory: histCopy }); |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
.catch((err) => { |
|||
console.error("Failed to save video generation:", err); |
|||
}); |
|||
} |
|||
} else { |
|||
updateNodeData(node.id, { |
|||
status: "error", |
|||
error: result.error || "Video generation failed", |
|||
}); |
|||
throw new Error(result.error || "Video generation failed"); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue