Browse Source

refactor: unify videoStitch and easeCurve executors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
faac9e0c50
  1. 215
      src/store/execution/__tests__/videoProcessingExecutors.test.ts
  2. 242
      src/store/execution/videoProcessingExecutors.ts

215
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> = {}
): 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<string, unknown> = {}): 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<typeof vi.fn>).mock.calls;
const loadingCall = calls.find(
(c: unknown[]) =>
(c[1] as Record<string, unknown>).status === "loading" &&
(c[1] as Record<string, unknown>).progress === 0
);
expect(loadingCall).toBeDefined();
});
});
describe("executeEaseCurve", () => {
function makeEaseNode(data: Record<string, unknown> = {}): 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<typeof vi.fn>).mock.calls;
const loadingCall = calls.find(
(c: unknown[]) =>
(c[1] as Record<string, unknown>).status === "loading" &&
(c[1] as Record<string, unknown>).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",
}));
});
});

242
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<void> {
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<string, unknown>
| 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<string>((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<void> {
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<number>((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<string, unknown>
| 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<string>((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,
});
}
}
Loading…
Cancel
Save