Browse Source

refactor: unify generateVideo execution into shared executor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
845ae98eac
  1. 274
      src/store/execution/__tests__/generateVideoExecutor.test.ts
  2. 195
      src/store/execution/generateVideoExecutor.ts

274
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<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
});
});

195
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<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…
Cancel
Save