From faac9e0c50e9d917471e9940d9f627623a5ad200 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sun, 8 Feb 2026 23:10:08 +1300 Subject: [PATCH] refactor: unify videoStitch and easeCurve executors Co-Authored-By: Claude Opus 4.6 --- .../videoProcessingExecutors.test.ts | 215 ++++++++++++++++ .../execution/videoProcessingExecutors.ts | 242 ++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 src/store/execution/__tests__/videoProcessingExecutors.test.ts create mode 100644 src/store/execution/videoProcessingExecutors.ts diff --git a/src/store/execution/__tests__/videoProcessingExecutors.test.ts b/src/store/execution/__tests__/videoProcessingExecutors.test.ts new file mode 100644 index 00000000..a84ca78d --- /dev/null +++ b/src/store/execution/__tests__/videoProcessingExecutors.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { executeVideoStitch, executeEaseCurve } from "../videoProcessingExecutors"; +import type { NodeExecutionContext } from "../types"; +import type { WorkflowNode } from "@/types"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +// Mock URL methods +vi.stubGlobal("URL", { + ...URL, + createObjectURL: vi.fn().mockReturnValue("blob:http://localhost/mock"), + revokeObjectURL: vi.fn(), +}); + +const defaultProviderSettings = { + providers: { + gemini: { apiKey: "" }, + replicate: { apiKey: "" }, + fal: { apiKey: "" }, + kie: { apiKey: "" }, + wavespeed: { apiKey: "" }, + openai: { apiKey: "" }, + }, +} as any; + +function makeCtx( + node: WorkflowNode, + overrides: Partial = {} +): NodeExecutionContext { + return { + node, + getConnectedInputs: vi.fn().mockReturnValue({ + images: [], + videos: [], + audio: [], + text: null, + 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("executeVideoStitch", () => { + function makeStitchNode(data: Record = {}): WorkflowNode { + return { + id: "vs-1", + type: "videoStitch", + position: { x: 0, y: 0 }, + data: { + outputVideo: null, + status: null, + error: null, + progress: 0, + encoderSupported: true, + loopCount: 1, + ...data, + }, + } as WorkflowNode; + } + + it("should error when encoder not supported", async () => { + const node = makeStitchNode({ encoderSupported: false }); + const ctx = makeCtx(node); + + await executeVideoStitch(ctx); + + expect(ctx.updateNodeData).toHaveBeenCalledWith("vs-1", expect.objectContaining({ + status: "error", + error: "Browser does not support video encoding", + })); + }); + + it("should error when fewer than 2 videos", async () => { + const node = makeStitchNode(); + const ctx = makeCtx(node, { + getConnectedInputs: vi.fn().mockReturnValue({ + images: [], + videos: ["single-video"], + audio: [], + text: null, + dynamicInputs: {}, + easeCurve: null, + }), + }); + + await executeVideoStitch(ctx); + + expect(ctx.updateNodeData).toHaveBeenCalledWith("vs-1", expect.objectContaining({ + status: "error", + error: "Need at least 2 video clips to stitch", + })); + }); + + it("should set loading status with 0 progress", async () => { + const node = makeStitchNode(); + const ctx = makeCtx(node); + + await executeVideoStitch(ctx); + + const calls = (ctx.updateNodeData as ReturnType).mock.calls; + const loadingCall = calls.find( + (c: unknown[]) => + (c[1] as Record).status === "loading" && + (c[1] as Record).progress === 0 + ); + expect(loadingCall).toBeDefined(); + }); +}); + +describe("executeEaseCurve", () => { + function makeEaseNode(data: Record = {}): WorkflowNode { + return { + id: "ec-1", + type: "easeCurve", + position: { x: 0, y: 0 }, + data: { + outputVideo: null, + status: null, + error: null, + progress: 0, + encoderSupported: true, + bezierHandles: [0.25, 0.1, 0.25, 1.0], + easingPreset: "ease-in-out", + outputDuration: 5, + ...data, + }, + } as WorkflowNode; + } + + it("should error when encoder not supported", async () => { + const node = makeEaseNode({ encoderSupported: false }); + const ctx = makeCtx(node); + + await executeEaseCurve(ctx); + + expect(ctx.updateNodeData).toHaveBeenCalledWith("ec-1", expect.objectContaining({ + status: "error", + error: "Browser does not support video encoding", + })); + }); + + it("should error when no video connected", async () => { + const node = makeEaseNode(); + const ctx = makeCtx(node); + + await executeEaseCurve(ctx); + + expect(ctx.updateNodeData).toHaveBeenCalledWith("ec-1", expect.objectContaining({ + status: "error", + error: "Connect a video input to apply ease curve", + })); + }); + + it("should set loading status with 0 progress", async () => { + const node = makeEaseNode(); + const ctx = makeCtx(node); + + await executeEaseCurve(ctx); + + const calls = (ctx.updateNodeData as ReturnType).mock.calls; + const loadingCall = calls.find( + (c: unknown[]) => + (c[1] as Record).status === "loading" && + (c[1] as Record).progress === 0 + ); + expect(loadingCall).toBeDefined(); + }); + + it("should propagate parent easeCurve settings", async () => { + const node = makeEaseNode({ + bezierHandles: [0, 0, 1, 1], + easingPreset: null, + }); + const ctx = makeCtx(node, { + getConnectedInputs: vi.fn().mockReturnValue({ + images: [], + videos: [], + audio: [], + text: null, + dynamicInputs: {}, + easeCurve: { + bezierHandles: [0.42, 0, 0.58, 1], + easingPreset: "ease-in-out", + }, + }), + getEdges: vi.fn().mockReturnValue([ + { id: "e1", source: "parent-ec", target: "ec-1", targetHandle: "easeCurve" }, + ]), + }); + + await executeEaseCurve(ctx); + + expect(ctx.updateNodeData).toHaveBeenCalledWith("ec-1", expect.objectContaining({ + bezierHandles: [0.42, 0, 0.58, 1], + easingPreset: "ease-in-out", + inheritedFrom: "parent-ec", + })); + }); +}); diff --git a/src/store/execution/videoProcessingExecutors.ts b/src/store/execution/videoProcessingExecutors.ts new file mode 100644 index 00000000..6ca3191e --- /dev/null +++ b/src/store/execution/videoProcessingExecutors.ts @@ -0,0 +1,242 @@ +/** + * Video Processing Executors + * + * Unified executors for videoStitch and easeCurve nodes. + * Used by both executeWorkflow and regenerateNode. + */ + +import type { VideoStitchNodeData, EaseCurveNodeData } from "@/types"; +import { revokeBlobUrl } from "@/store/utils/executionUtils"; +import type { NodeExecutionContext } from "./types"; + +/** + * VideoStitch: combines multiple video clips into a single output. + */ +export async function executeVideoStitch(ctx: NodeExecutionContext): Promise { + const { node, getConnectedInputs, updateNodeData, getNodes } = ctx; + const nodeData = node.data as VideoStitchNodeData; + + if (nodeData.encoderSupported === false) { + updateNodeData(node.id, { + status: "error", + error: "Browser does not support video encoding", + progress: 0, + }); + return; + } + + updateNodeData(node.id, { status: "loading", progress: 0, error: null }); + + try { + const inputs = getConnectedInputs(node.id); + + if (inputs.videos.length < 2) { + updateNodeData(node.id, { + status: "error", + error: "Need at least 2 video clips to stitch", + progress: 0, + }); + return; + } + + const videoBlobs = await Promise.all( + inputs.videos.map((v) => fetch(v).then((r) => r.blob())) + ); + + // Duplicate blobs based on loopCount (2x or 3x repeats the sequence) + const loopCount = nodeData.loopCount || 1; + const loopedBlobs = + loopCount > 1 + ? Array.from({ length: loopCount }, () => + videoBlobs.map((b) => new Blob([b], { type: b.type })) + ).flat() + : videoBlobs; + + // Prepare audio if connected + let audioData = null; + if (inputs.audio.length > 0 && inputs.audio[0]) { + const { prepareAudioAsync } = await import("@/hooks/useAudioMixing"); + const audioUrl = inputs.audio[0]; + const audioResponse = await fetch(audioUrl); + const rawBlob = await audioResponse.blob(); + const audioMime = + rawBlob.type || + (audioUrl.startsWith("data:") + ? audioUrl.split(";")[0].split(":")[1] + : "audio/mpeg"); + const audioBlob = rawBlob.type + ? rawBlob + : new Blob([rawBlob], { type: audioMime }); + audioData = await prepareAudioAsync(audioBlob, 0); + } + + const { stitchVideosAsync } = await import("@/hooks/useStitchVideos"); + const outputBlob = await stitchVideosAsync( + loopedBlobs, + audioData, + (progress) => { + updateNodeData(node.id, { progress: progress.progress }); + } + ); + + // Revoke old blob URL before replacing + const oldData = getNodes().find((n) => n.id === node.id)?.data as + | Record + | undefined; + revokeBlobUrl(oldData?.outputVideo as string | undefined); + + let outputVideo: string; + if (outputBlob.size > 20 * 1024 * 1024) { + outputVideo = URL.createObjectURL(outputBlob); + } else { + const reader = new FileReader(); + outputVideo = await new Promise((resolve) => { + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(outputBlob); + }); + } + + updateNodeData(node.id, { + outputVideo, + status: "complete", + progress: 100, + error: null, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Stitch failed"; + updateNodeData(node.id, { + status: "error", + error: errorMessage, + progress: 0, + }); + } +} + +/** + * EaseCurve: applies speed curve to a video input. + */ +export async function executeEaseCurve(ctx: NodeExecutionContext): Promise { + const { node, getConnectedInputs, updateNodeData, getEdges, getNodes } = ctx; + const nodeData = node.data as EaseCurveNodeData; + + if (nodeData.encoderSupported === false) { + updateNodeData(node.id, { + status: "error", + error: "Browser does not support video encoding", + progress: 0, + }); + return; + } + + updateNodeData(node.id, { status: "loading", progress: 0, error: null }); + + try { + const inputs = getConnectedInputs(node.id); + + // Propagate parent easeCurve settings if inherited + let activeBezierHandles = nodeData.bezierHandles; + let activeEasingPreset = nodeData.easingPreset; + if (inputs.easeCurve) { + activeBezierHandles = inputs.easeCurve.bezierHandles; + activeEasingPreset = inputs.easeCurve.easingPreset; + const edges = getEdges(); + const easeCurveSourceId = + edges.filter( + (e) => e.target === node.id && e.targetHandle === "easeCurve" + )[0]?.source ?? null; + updateNodeData(node.id, { + bezierHandles: activeBezierHandles, + easingPreset: activeEasingPreset, + inheritedFrom: easeCurveSourceId, + }); + } + + if (inputs.videos.length === 0) { + updateNodeData(node.id, { + status: "error", + error: "Connect a video input to apply ease curve", + progress: 0, + }); + return; + } + + const videoUrl = inputs.videos[0]; + const videoBlob = await fetch(videoUrl).then((r) => r.blob()); + + // Get video duration for warpTime input + const videoDuration = await new Promise((resolve) => { + const video = document.createElement("video"); + video.preload = "metadata"; + video.onloadedmetadata = () => { + resolve(video.duration); + URL.revokeObjectURL(video.src); + }; + video.onerror = () => { + resolve(5); // Fallback to 5 seconds + URL.revokeObjectURL(video.src); + }; + video.src = URL.createObjectURL(videoBlob); + }); + + // Determine easing function: use named preset if set, otherwise create from Bezier handles + let easingFunction: string | ((t: number) => number); + if (activeEasingPreset) { + easingFunction = activeEasingPreset; + } else { + const { createBezierEasing } = await import("@/lib/easing-functions"); + easingFunction = createBezierEasing( + activeBezierHandles[0], + activeBezierHandles[1], + activeBezierHandles[2], + activeBezierHandles[3] + ); + } + + const { applySpeedCurveAsync } = await import("@/hooks/useApplySpeedCurve"); + const outputBlob = await applySpeedCurveAsync( + videoBlob, + videoDuration, + nodeData.outputDuration, + (progress) => { + updateNodeData(node.id, { progress: progress.progress }); + }, + easingFunction + ); + + if (!outputBlob) { + throw new Error("Speed curve processing returned no output"); + } + + // Revoke old blob URL before replacing + const oldData = getNodes().find((n) => n.id === node.id)?.data as + | Record + | undefined; + revokeBlobUrl(oldData?.outputVideo as string | undefined); + + let outputVideo: string; + if (outputBlob.size > 20 * 1024 * 1024) { + outputVideo = URL.createObjectURL(outputBlob); + } else { + const reader = new FileReader(); + outputVideo = await new Promise((resolve) => { + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(outputBlob); + }); + } + + updateNodeData(node.id, { + outputVideo, + status: "complete", + progress: 100, + error: null, + }); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Ease curve processing failed"; + updateNodeData(node.id, { + status: "error", + error: errorMessage, + progress: 0, + }); + } +}