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