2 changed files with 457 additions and 0 deletions
@ -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", |
||||
|
})); |
||||
|
}); |
||||
|
}); |
||||
@ -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…
Reference in new issue