From 845ae98eac360bd3ab980bff324f5fe2c1d77a20 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sun, 8 Feb 2026 23:05:39 +1300 Subject: [PATCH] refactor: unify generateVideo execution into shared executor Co-Authored-By: Claude Opus 4.6 --- .../__tests__/generateVideoExecutor.test.ts | 274 ++++++++++++++++++ src/store/execution/generateVideoExecutor.ts | 195 +++++++++++++ 2 files changed, 469 insertions(+) create mode 100644 src/store/execution/__tests__/generateVideoExecutor.test.ts create mode 100644 src/store/execution/generateVideoExecutor.ts diff --git a/src/store/execution/__tests__/generateVideoExecutor.test.ts b/src/store/execution/__tests__/generateVideoExecutor.test.ts new file mode 100644 index 00000000..cf0ba794 --- /dev/null +++ b/src/store/execution/__tests__/generateVideoExecutor.test.ts @@ -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 = {}): 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 { + 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).mock.calls; + const loadingCall = calls.find( + (c: unknown[]) => (c[1] as Record).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).mock.calls; + const completeCall = calls.find( + (c: unknown[]) => (c[1] as Record).status === "complete" + ); + expect(completeCall).toBeDefined(); + expect((completeCall![1] as Record).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).mock.calls; + const completeCall = calls.find( + (c: unknown[]) => (c[1] as Record).status === "complete" + ); + expect((completeCall![1] as Record).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).mock.calls; + const completeCall = calls.find( + (c: unknown[]) => (c[1] as Record).status === "complete" + ); + expect((completeCall![1] as Record).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).mock.calls; + const completeCall = calls.find( + (c: unknown[]) => (c[1] as Record).status === "complete" + ); + const videoHistory = (completeCall![1] as Record).videoHistory as unknown[]; + expect(videoHistory.length).toBe(50); // capped at 50 + }); +}); diff --git a/src/store/execution/generateVideoExecutor.ts b/src/store/execution/generateVideoExecutor.ts new file mode 100644 index 00000000..574c524c --- /dev/null +++ b/src/store/execution/generateVideoExecutor.ts @@ -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 { + 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"); + } +}