diff --git a/lib/agents/content/__tests__/downloadVideoBuffer.test.ts b/lib/agents/content/__tests__/downloadVideoBuffer.test.ts new file mode 100644 index 00000000..ae6549cf --- /dev/null +++ b/lib/agents/content/__tests__/downloadVideoBuffer.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { downloadVideoBuffer } from "../downloadVideoBuffer"; + +describe("downloadVideoBuffer", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it("returns a Buffer with the video data on success", async () => { + const fakeData = new Uint8Array([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70]); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(fakeData, { + status: 200, + headers: { "content-type": "video/mp4" }, + }), + ); + + const result = await downloadVideoBuffer("https://example.com/video.mp4"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBe(fakeData.length); + }); + + it("returns null when fetch returns a non-ok status", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not Found", { status: 404 })); + + const result = await downloadVideoBuffer("https://example.com/missing.mp4"); + expect(result).toBeNull(); + }); + + it("returns null when fetch throws a network error", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error")); + + const result = await downloadVideoBuffer("https://example.com/video.mp4"); + expect(result).toBeNull(); + }); + + it("extracts the filename from the URL path", async () => { + const fakeData = new Uint8Array([0x01, 0x02]); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(fakeData, { status: 200 })); + + const result = await downloadVideoBuffer( + "https://cdn.example.com/path/to/my-video.mp4?token=abc", + ); + expect(result).toBeInstanceOf(Buffer); + }); +}); diff --git a/lib/agents/content/__tests__/getFilenameFromUrl.test.ts b/lib/agents/content/__tests__/getFilenameFromUrl.test.ts new file mode 100644 index 00000000..42fea32c --- /dev/null +++ b/lib/agents/content/__tests__/getFilenameFromUrl.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { getFilenameFromUrl } from "../getFilenameFromUrl"; + +describe("getFilenameFromUrl", () => { + it("extracts filename from a simple URL", () => { + expect(getFilenameFromUrl("https://cdn.example.com/path/to/video.mp4")).toBe("video.mp4"); + }); + + it("extracts filename from URL with query params", () => { + expect(getFilenameFromUrl("https://cdn.example.com/video.mp4?token=abc&t=123")).toBe( + "video.mp4", + ); + }); + + it("handles URL-encoded characters", () => { + expect(getFilenameFromUrl("https://cdn.example.com/my%20video%20file.mp4")).toBe( + "my%20video%20file.mp4", + ); + }); + + it("falls back to video.mp4 when URL has no extension", () => { + expect(getFilenameFromUrl("https://cdn.example.com/path/to/video")).toBe("video.mp4"); + }); + + it("falls back to video.mp4 when URL path ends with slash", () => { + expect(getFilenameFromUrl("https://cdn.example.com/path/")).toBe("video.mp4"); + }); + + it("falls back to video.mp4 for invalid URLs", () => { + expect(getFilenameFromUrl("not-a-url")).toBe("video.mp4"); + }); + + it("handles fal.ai storage URLs", () => { + expect( + getFilenameFromUrl( + "https://v3b.fal.media/files/b/0a9486c8/sjfeqG-MFh_3aG213aIU2_final-video.mp4", + ), + ).toBe("sjfeqG-MFh_3aG213aIU2_final-video.mp4"); + }); +}); diff --git a/lib/agents/content/__tests__/handleContentAgentCallback.test.ts b/lib/agents/content/__tests__/handleContentAgentCallback.test.ts index c334ae8d..36fa4ea1 100644 --- a/lib/agents/content/__tests__/handleContentAgentCallback.test.ts +++ b/lib/agents/content/__tests__/handleContentAgentCallback.test.ts @@ -13,6 +13,18 @@ vi.mock("@/lib/agents/getThread", () => ({ getThread: vi.fn(), })); +vi.mock("../postVideoResults", () => ({ + postVideoResults: vi.fn().mockResolvedValue(undefined), +})); + +const { validateContentAgentCallback } = await import("../validateContentAgentCallback"); +const { getThread } = await import("@/lib/agents/getThread"); +const { postVideoResults } = await import("../postVideoResults"); + +const mockedValidate = vi.mocked(validateContentAgentCallback); +const mockedGetThread = vi.mocked(getThread); +const mockedPostVideos = vi.mocked(postVideoResults); + describe("handleContentAgentCallback", () => { const originalEnv = { ...process.env }; @@ -70,4 +82,102 @@ describe("handleContentAgentCallback", () => { // Should get past auth and fail on invalid JSON (400), not auth (401) expect(response.status).toBe(400); }); + + describe("completed callback with videos", () => { + function makeAuthRequest(body: object) { + return new Request("http://localhost/api/content-agent/callback", { + method: "POST", + headers: { "x-callback-secret": "test-secret" }, + body: JSON.stringify(body), + }); + } + + function mockThread() { + const thread = { + post: vi.fn().mockResolvedValue(undefined), + state: Promise.resolve({ status: "running" }), + setState: vi.fn().mockResolvedValue(undefined), + }; + mockedGetThread.mockReturnValue(thread as never); + return thread; + } + + it("calls postVideoResults with videos and failed count", async () => { + const thread = mockThread(); + mockedValidate.mockReturnValue({ + threadId: "slack:C123:T456", + status: "completed", + results: [ + { + runId: "run-1", + status: "completed", + videoUrl: "https://cdn.example.com/video.mp4", + captionText: "Test", + }, + { runId: "run-2", status: "failed", error: "render error" }, + ], + }); + + const response = await handleContentAgentCallback(makeAuthRequest({})); + + expect(response.status).toBe(200); + expect(mockedPostVideos).toHaveBeenCalledWith( + thread, + [expect.objectContaining({ videoUrl: "https://cdn.example.com/video.mp4" })], + 1, + ); + }); + + it("posts fallback message when no videos produced", async () => { + const thread = mockThread(); + mockedValidate.mockReturnValue({ + threadId: "slack:C123:T456", + status: "completed", + results: [{ runId: "run-1", status: "failed", error: "render error" }], + }); + + const response = await handleContentAgentCallback(makeAuthRequest({})); + + expect(response.status).toBe(200); + expect(thread.post).toHaveBeenCalledWith( + "Content generation finished but no videos were produced.", + ); + expect(mockedPostVideos).not.toHaveBeenCalled(); + }); + + it("skips duplicate delivery when thread status is not running", async () => { + const thread = { + post: vi.fn(), + state: Promise.resolve({ status: "completed" }), + setState: vi.fn(), + }; + mockedGetThread.mockReturnValue(thread as never); + mockedValidate.mockReturnValue({ + threadId: "slack:C123:T456", + status: "completed", + results: [], + }); + + const response = await handleContentAgentCallback(makeAuthRequest({})); + const body = await response.json(); + + expect(body.skipped).toBe(true); + expect(thread.post).not.toHaveBeenCalled(); + }); + + it("sets thread state to completed after posting", async () => { + const thread = mockThread(); + mockedValidate.mockReturnValue({ + threadId: "slack:C123:T456", + status: "completed", + results: [ + { runId: "run-1", status: "completed", videoUrl: "https://cdn.example.com/v.mp4" }, + ], + }); + + await handleContentAgentCallback(makeAuthRequest({})); + + expect(thread.setState).toHaveBeenCalledWith({ status: "completed" }); + }); + }); }); diff --git a/lib/agents/content/__tests__/postVideoResults.test.ts b/lib/agents/content/__tests__/postVideoResults.test.ts new file mode 100644 index 00000000..33ba88c9 --- /dev/null +++ b/lib/agents/content/__tests__/postVideoResults.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { postVideoResults } from "../postVideoResults"; + +vi.mock("../downloadVideoBuffer", () => ({ + downloadVideoBuffer: vi.fn(), +})); + +const { downloadVideoBuffer } = await import("../downloadVideoBuffer"); +const mockedDownload = vi.mocked(downloadVideoBuffer); + +describe("postVideoResults", () => { + let thread: { post: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + thread = { post: vi.fn().mockResolvedValue(undefined) }; + }); + + it("downloads videos in parallel and posts each as a file upload", async () => { + const buf1 = Buffer.from([0x01]); + const buf2 = Buffer.from([0x02]); + mockedDownload.mockResolvedValueOnce(buf1).mockResolvedValueOnce(buf2); + + const videos = [ + { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v1.mp4" }, + { runId: "r2", status: "completed" as const, videoUrl: "https://cdn.example.com/v2.mp4" }, + ]; + + await postVideoResults(thread as never, videos, 0); + + expect(mockedDownload).toHaveBeenCalledTimes(2); + expect(thread.post).toHaveBeenCalledTimes(2); + expect(thread.post).toHaveBeenCalledWith( + expect.objectContaining({ + files: [expect.objectContaining({ filename: "v1.mp4" })], + }), + ); + }); + + it("falls back to URL link when download fails", async () => { + mockedDownload.mockResolvedValue(null); + + const videos = [ + { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" }, + ]; + + await postVideoResults(thread as never, videos, 0); + + expect(thread.post).toHaveBeenCalledWith( + expect.stringContaining("https://cdn.example.com/v.mp4"), + ); + }); + + it("includes caption in markdown when present", async () => { + mockedDownload.mockResolvedValue(Buffer.from([0x01])); + + const videos = [ + { + runId: "r1", + status: "completed" as const, + videoUrl: "https://cdn.example.com/v.mp4", + captionText: "great song", + }, + ]; + + await postVideoResults(thread as never, videos, 0); + + expect(thread.post).toHaveBeenCalledWith( + expect.objectContaining({ + markdown: expect.stringContaining("great song"), + }), + ); + }); + + it("posts failed run count when failedCount > 0", async () => { + mockedDownload.mockResolvedValue(Buffer.from([0x01])); + + const videos = [ + { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" }, + ]; + + await postVideoResults(thread as never, videos, 2); + + const lastCall = thread.post.mock.calls[thread.post.mock.calls.length - 1][0]; + expect(lastCall).toContain("2"); + expect(lastCall).toContain("failed"); + }); + + it("does not post failed message when failedCount is 0", async () => { + mockedDownload.mockResolvedValue(Buffer.from([0x01])); + + const videos = [ + { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" }, + ]; + + await postVideoResults(thread as never, videos, 0); + + expect(thread.post).toHaveBeenCalledTimes(1); + }); + + it("labels videos when there are multiple", async () => { + mockedDownload.mockResolvedValue(Buffer.from([0x01])); + + const videos = [ + { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v1.mp4" }, + { runId: "r2", status: "completed" as const, videoUrl: "https://cdn.example.com/v2.mp4" }, + ]; + + await postVideoResults(thread as never, videos, 0); + + expect(thread.post).toHaveBeenCalledWith( + expect.objectContaining({ + markdown: expect.stringContaining("Video 1"), + }), + ); + }); +}); diff --git a/lib/agents/content/downloadVideoBuffer.ts b/lib/agents/content/downloadVideoBuffer.ts new file mode 100644 index 00000000..e6694431 --- /dev/null +++ b/lib/agents/content/downloadVideoBuffer.ts @@ -0,0 +1,22 @@ +/** + * Downloads a video from a URL and returns the data as a Buffer. + * + * @param url - The video URL to download + * @returns The video data as a Buffer, or null if the download fails + */ +export async function downloadVideoBuffer(url: string): Promise { + try { + const response = await fetch(url); + + if (!response.ok) { + console.error(`Failed to download video: HTTP ${response.status} from ${url}`); + return null; + } + + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } catch (error) { + console.error("Failed to download video:", error); + return null; + } +} diff --git a/lib/agents/content/getFilenameFromUrl.ts b/lib/agents/content/getFilenameFromUrl.ts new file mode 100644 index 00000000..df3120ae --- /dev/null +++ b/lib/agents/content/getFilenameFromUrl.ts @@ -0,0 +1,16 @@ +/** + * Extracts the filename from a URL path, falling back to "video.mp4". + * + * @param url - The video URL + * @returns The extracted filename + */ +export function getFilenameFromUrl(url: string): string { + try { + const pathname = new URL(url).pathname; + const segments = pathname.split("/"); + const last = segments[segments.length - 1]; + return last && last.includes(".") ? last : "video.mp4"; + } catch { + return "video.mp4"; + } +} diff --git a/lib/agents/content/handleContentAgentCallback.ts b/lib/agents/content/handleContentAgentCallback.ts index de01f754..52892dd3 100644 --- a/lib/agents/content/handleContentAgentCallback.ts +++ b/lib/agents/content/handleContentAgentCallback.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateContentAgentCallback } from "./validateContentAgentCallback"; import { getThread } from "@/lib/agents/getThread"; +import { postVideoResults } from "./postVideoResults"; import type { ContentAgentThreadState } from "./types"; /** @@ -62,17 +63,7 @@ export async function handleContentAgentCallback(request: Request): Promise r.status === "failed"); if (videos.length > 0) { - const lines = videos.map((v, i) => { - const label = videos.length > 1 ? `**Video ${i + 1}:** ` : ""; - const caption = v.captionText ? `\n> ${v.captionText}` : ""; - return `${label}${v.videoUrl}${caption}`; - }); - - if (failed.length > 0) { - lines.push(`\n_${failed.length} run(s) failed._`); - } - - await thread.post(lines.join("\n\n")); + await postVideoResults(thread, videos, failed.length); } else { await thread.post("Content generation finished but no videos were produced."); } diff --git a/lib/agents/content/postVideoResults.ts b/lib/agents/content/postVideoResults.ts new file mode 100644 index 00000000..36bd9c13 --- /dev/null +++ b/lib/agents/content/postVideoResults.ts @@ -0,0 +1,66 @@ +import { downloadVideoBuffer } from "./downloadVideoBuffer"; +import { getFilenameFromUrl } from "./getFilenameFromUrl"; + +interface VideoResult { + runId: string; + status: string; + videoUrl?: string; + captionText?: string; +} + +interface Thread { + post: ( + message: + | string + | { markdown: string; files: { data: Buffer; filename: string; mimeType: string }[] }, + ) => Promise; +} + +/** + * Downloads completed videos in parallel and posts each to the thread. + * Falls back to posting the URL as text if a download fails. + * + * @param thread - The thread to post results to + * @param videos - Array of completed video results + * @param failedCount - Number of failed runs to report + */ +export async function postVideoResults( + thread: Thread, + videos: VideoResult[], + failedCount: number, +): Promise { + // Download all videos in parallel + const buffers = await Promise.all(videos.map(v => downloadVideoBuffer(v.videoUrl!))); + + // Post each video sequentially (Slack ordering matters) + for (let i = 0; i < videos.length; i++) { + const v = videos[i]; + const videoBuffer = buffers[i]; + + if (videoBuffer) { + const filename = getFilenameFromUrl(v.videoUrl!); + const label = videos.length > 1 ? `**Video ${i + 1}**` : ""; + const caption = v.captionText ? `> ${v.captionText}` : ""; + const markdown = [label, caption].filter(Boolean).join("\n"); + + await thread.post({ + markdown: markdown || filename, + files: [ + { + data: videoBuffer, + filename, + mimeType: "video/mp4", + }, + ], + }); + } else { + const label = videos.length > 1 ? `**Video ${i + 1}:** ` : ""; + const caption = v.captionText ? `\n> ${v.captionText}` : ""; + await thread.post(`${label}${v.videoUrl}${caption}`); + } + } + + if (failedCount > 0) { + await thread.post(`_${failedCount} run(s) failed._`); + } +}