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