diff --git a/app/api/video/render/route.ts b/app/api/video/render/route.ts new file mode 100644 index 0000000..ceb90b3 --- /dev/null +++ b/app/api/video/render/route.ts @@ -0,0 +1,49 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { renderVideoHandler } from "@/lib/render/renderVideoHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/video/render + * + * Triggers a server-side video render as a background task. Returns + * immediately with a Trigger.dev run ID that can be polled via + * GET /api/tasks/runs?runId= for status and the rendered video URL. + * + * Authentication: x-api-key header or Authorization Bearer token required. + * + * Request body: + * - compositionId: string (required) - The composition to render + * - inputProps: object (optional) - Props to pass to the composition + * - width: number (optional, default 720) + * - height: number (optional, default 1280) + * - fps: number (optional, default 30) + * - durationInFrames: number (optional, default 240) + * - codec: "h264" | "h265" | "vp8" | "vp9" (optional, default "h264") + * + * Response (200): + * - status: "processing" + * - runId: string - The Trigger.dev run ID for the render task + * + * Error (400/401/500): + * - status: "error" + * - error: string + * + * @param request - The request object + * @returns A NextResponse with the render result or error + */ +export async function POST(request: NextRequest): Promise { + return renderVideoHandler(request); +} diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index 36b462d..fef1cfb 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -19,6 +19,7 @@ import { registerSendEmailTool } from "./registerSendEmailTool"; import { registerAllArtistTools } from "./artists"; import { registerAllChatsTools } from "./chats"; import { registerAllPulseTools } from "./pulse"; +import { registerAllRenderTools } from "./render"; /** * Registers all MCP tools on the server. @@ -37,6 +38,7 @@ export const registerAllTools = (server: McpServer): void => { registerAllFileTools(server); registerAllImageTools(server); registerAllPulseTools(server); + registerAllRenderTools(server); registerAllSearchTools(server); registerAllSora2Tools(server); registerAllSpotifyTools(server); diff --git a/lib/mcp/tools/render/index.ts b/lib/mcp/tools/render/index.ts new file mode 100644 index 0000000..fd6d4e0 --- /dev/null +++ b/lib/mcp/tools/render/index.ts @@ -0,0 +1,11 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerRenderVideoTool } from "./registerRenderVideoTool"; + +/** + * Registers all render-related MCP tools. + * + * @param server - The MCP server instance + */ +export function registerAllRenderTools(server: McpServer): void { + registerRenderVideoTool(server); +} diff --git a/lib/mcp/tools/render/registerRenderVideoTool.ts b/lib/mcp/tools/render/registerRenderVideoTool.ts new file mode 100644 index 0000000..38766fb --- /dev/null +++ b/lib/mcp/tools/render/registerRenderVideoTool.ts @@ -0,0 +1,101 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { triggerRenderVideo } from "@/lib/trigger/triggerRenderVideo"; + +/** Schema for the render_video MCP tool arguments. */ +const renderVideoSchema = z.object({ + compositionId: z + .string() + .describe( + 'The composition ID to render (e.g., "SocialPost", "UpdatesAnnouncement", "CommitShowcase")', + ), + inputProps: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Input props to pass to the composition. Structure depends on the composition being rendered.", + ), + width: z + .number() + .int() + .min(1) + .max(3840) + .optional() + .describe("Output width in pixels (default 720)"), + height: z + .number() + .int() + .min(1) + .max(3840) + .optional() + .describe("Output height in pixels (default 1280)"), + fps: z.number().int().min(1).max(60).optional().describe("Frames per second (default 30)"), + durationInFrames: z + .number() + .int() + .min(1) + .max(1800) + .optional() + .describe("Total frames to render. At 30 fps, 240 frames = 8 seconds (default 240)"), + codec: z.enum(["h264", "h265", "vp8", "vp9"]).optional().describe('Video codec (default "h264")'), +}); + +/** + * Registers the "render_video" MCP tool. + * + * Triggers a server-side video render as a background task and returns + * a run ID that can be polled for status. Uses the same shared + * triggerRenderVideo function as the REST API endpoint. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerRenderVideoTool(server: McpServer): void { + server.registerTool( + "render_video", + { + description: `Trigger a server-side video render. Returns a run ID that can be polled for status and the rendered video URL. + +IMPORTANT: +- compositionId is required — it must match a registered composition +- inputProps vary by composition (e.g., SocialPost needs videoUrl, captionText) +- The render runs in the background; poll the returned runId for completion +- Default output: 720×1280 at 30 fps, 8 seconds, h264 codec`, + inputSchema: renderVideoSchema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const accountId = authInfo?.extra?.accountId; + + if (!accountId) { + return getToolResultError("Authentication required."); + } + + try { + const handle = await triggerRenderVideo({ + compositionId: args.compositionId, + inputProps: args.inputProps ?? {}, + width: args.width ?? 720, + height: args.height ?? 1280, + fps: args.fps ?? 30, + durationInFrames: args.durationInFrames ?? 240, + codec: args.codec ?? "h264", + accountId, + }); + + return getToolResultSuccess({ + status: "processing", + runId: handle.id, + message: "Video render triggered. Poll for status using the runId.", + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to trigger video render"; + return getToolResultError(message); + } + }, + ); +} diff --git a/lib/render/__tests__/renderVideoHandler.test.ts b/lib/render/__tests__/renderVideoHandler.test.ts new file mode 100644 index 0000000..7bacb49 --- /dev/null +++ b/lib/render/__tests__/renderVideoHandler.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { renderVideoHandler } from "@/lib/render/renderVideoHandler"; + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateRenderVideoBody } from "@/lib/render/validateRenderVideoBody"; +import { triggerRenderVideo } from "@/lib/trigger/triggerRenderVideo"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/render/validateRenderVideoBody", () => ({ + validateRenderVideoBody: vi.fn(), +})); + +vi.mock("@/lib/trigger/triggerRenderVideo", () => ({ + triggerRenderVideo: vi.fn(), +})); + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(), +})); + +describe("renderVideoHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns auth error when authentication fails", async () => { + const errorResponse = NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const request = new NextRequest("http://localhost/api/video/render", { + method: "POST", + body: JSON.stringify({ compositionId: "SocialPost" }), + headers: { "Content-Type": "application/json" }, + }); + + const result = await renderVideoHandler(request); + + expect(result).toBe(errorResponse); + }); + + it("returns validation error when body validation fails", async () => { + // Auth passes + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-account-id", + orgId: null, + authToken: "test-token", + }); + + const errorResponse = NextResponse.json( + { status: "error", error: "compositionId is required" }, + { status: 400 }, + ); + vi.mocked(validateRenderVideoBody).mockReturnValue(errorResponse); + + const request = new NextRequest("http://localhost/api/video/render", { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }); + + const result = await renderVideoHandler(request); + + expect(result).toBe(errorResponse); + }); + + it("returns processing status with runId on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-account-id", + orgId: null, + authToken: "test-token", + }); + + vi.mocked(validateRenderVideoBody).mockReturnValue({ + compositionId: "SocialPost", + inputProps: { videoUrl: "https://example.com/video.mp4" }, + width: 720, + height: 1280, + fps: 30, + durationInFrames: 240, + codec: "h264", + }); + + vi.mocked(triggerRenderVideo).mockResolvedValue({ + id: "run_render_abc123", + } as never); + + const request = new NextRequest("http://localhost/api/video/render", { + method: "POST", + body: JSON.stringify({ compositionId: "SocialPost" }), + headers: { "Content-Type": "application/json" }, + }); + + const result = await renderVideoHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body).toEqual({ + status: "processing", + runId: "run_render_abc123", + }); + }); + + it("passes accountId to triggerRenderVideo", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "my-account-id", + orgId: null, + authToken: "test-token", + }); + + vi.mocked(validateRenderVideoBody).mockReturnValue({ + compositionId: "SocialPost", + inputProps: {}, + width: 720, + height: 1280, + fps: 30, + durationInFrames: 240, + codec: "h264", + }); + + vi.mocked(triggerRenderVideo).mockResolvedValue({ + id: "run_123", + } as never); + + const request = new NextRequest("http://localhost/api/video/render", { + method: "POST", + body: JSON.stringify({ compositionId: "SocialPost" }), + headers: { "Content-Type": "application/json" }, + }); + + await renderVideoHandler(request); + + expect(triggerRenderVideo).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "my-account-id", + compositionId: "SocialPost", + }), + ); + }); + + it("returns 500 when trigger fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-account-id", + orgId: null, + authToken: "test-token", + }); + + vi.mocked(validateRenderVideoBody).mockReturnValue({ + compositionId: "SocialPost", + inputProps: {}, + width: 720, + height: 1280, + fps: 30, + durationInFrames: 240, + codec: "h264", + }); + + vi.mocked(triggerRenderVideo).mockRejectedValue(new Error("Trigger.dev connection failed")); + + const request = new NextRequest("http://localhost/api/video/render", { + method: "POST", + body: JSON.stringify({ compositionId: "SocialPost" }), + headers: { "Content-Type": "application/json" }, + }); + + const result = await renderVideoHandler(request); + const body = await result.json(); + + expect(result.status).toBe(500); + expect(body).toEqual({ + status: "error", + error: "Trigger.dev connection failed", + }); + }); +}); diff --git a/lib/render/__tests__/validateRenderVideoBody.test.ts b/lib/render/__tests__/validateRenderVideoBody.test.ts new file mode 100644 index 0000000..d085d52 --- /dev/null +++ b/lib/render/__tests__/validateRenderVideoBody.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; + +import { + validateRenderVideoBody, + type RenderVideoBody, +} from "@/lib/render/validateRenderVideoBody"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +describe("validateRenderVideoBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful validation", () => { + it("accepts a valid body with only compositionId", () => { + const result = validateRenderVideoBody({ + compositionId: "SocialPost", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + const validated = result as RenderVideoBody; + expect(validated.compositionId).toBe("SocialPost"); + // Defaults should be applied + expect(validated.inputProps).toEqual({}); + expect(validated.width).toBe(720); + expect(validated.height).toBe(1280); + expect(validated.fps).toBe(30); + expect(validated.durationInFrames).toBe(240); + expect(validated.codec).toBe("h264"); + }); + + it("accepts a valid body with all fields", () => { + const result = validateRenderVideoBody({ + compositionId: "CommitShowcase", + inputProps: { videoUrl: "https://example.com/video.mp4" }, + width: 1080, + height: 1920, + fps: 60, + durationInFrames: 900, + codec: "vp9", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + const validated = result as RenderVideoBody; + expect(validated.compositionId).toBe("CommitShowcase"); + expect(validated.inputProps).toEqual({ + videoUrl: "https://example.com/video.mp4", + }); + expect(validated.width).toBe(1080); + expect(validated.height).toBe(1920); + expect(validated.fps).toBe(60); + expect(validated.durationInFrames).toBe(900); + expect(validated.codec).toBe("vp9"); + }); + + it("accepts all supported codecs", () => { + const codecs = ["h264", "h265", "vp8", "vp9"] as const; + + for (const codec of codecs) { + const result = validateRenderVideoBody({ + compositionId: "SocialPost", + codec, + }); + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as RenderVideoBody).codec).toBe(codec); + } + }); + }); + + describe("error cases", () => { + it("returns 400 when compositionId is missing", () => { + const result = validateRenderVideoBody({}); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when compositionId is empty string", () => { + const result = validateRenderVideoBody({ compositionId: "" }); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when width exceeds maximum", () => { + const result = validateRenderVideoBody({ + compositionId: "SocialPost", + width: 5000, + }); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when height is zero", () => { + const result = validateRenderVideoBody({ + compositionId: "SocialPost", + height: 0, + }); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when fps exceeds maximum", () => { + const result = validateRenderVideoBody({ + compositionId: "SocialPost", + fps: 120, + }); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when durationInFrames exceeds maximum", () => { + const result = validateRenderVideoBody({ + compositionId: "SocialPost", + durationInFrames: 5000, + }); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when codec is invalid", () => { + const result = validateRenderVideoBody({ + compositionId: "SocialPost", + codec: "mp4", + }); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("includes error message in response body", async () => { + const result = validateRenderVideoBody({}) as NextResponse; + const body = await result.json(); + + expect(body.status).toBe("error"); + expect(body.error).toBeDefined(); + }); + }); +}); diff --git a/lib/render/renderVideoHandler.ts b/lib/render/renderVideoHandler.ts new file mode 100644 index 0000000..acfd637 --- /dev/null +++ b/lib/render/renderVideoHandler.ts @@ -0,0 +1,56 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateRenderVideoBody } from "@/lib/render/validateRenderVideoBody"; +import { triggerRenderVideo } from "@/lib/trigger/triggerRenderVideo"; + +/** + * Handler for POST /api/video/render. + * + * Authenticates the request, validates the body, then triggers the + * render-video background task on Trigger.dev. Returns immediately + * with a run ID that can be polled via GET /api/tasks/runs. + * + * @param request - The incoming Next.js request. + * @returns A NextResponse with { status: "processing", runId } on success, + * or an error response on failure. + */ +export async function renderVideoHandler(request: NextRequest): Promise { + // 1. Authenticate — supports x-api-key and Authorization Bearer + const authResult = await validateAuthContext(request); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId } = authResult; + + // 2. Parse and validate the request body + const body = await safeParseJson(request); + const validated = validateRenderVideoBody(body); + + if (validated instanceof NextResponse) { + return validated; + } + + // 3. Trigger the render task on Trigger.dev + try { + const handle = await triggerRenderVideo({ + ...validated, + accountId, + }); + + return NextResponse.json( + { status: "processing", runId: handle.id }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to trigger video render"; + return NextResponse.json( + { status: "error", error: message }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/render/validateRenderVideoBody.ts b/lib/render/validateRenderVideoBody.ts new file mode 100644 index 0000000..fd9f78f --- /dev/null +++ b/lib/render/validateRenderVideoBody.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +/** + * Zod schema for the POST /api/video/render request body. + * + * Only `compositionId` is required. All other fields have sensible defaults + * (720×1280, 30 fps, 240 frames = 8 seconds, h264 codec). + */ +export const renderVideoBodySchema = z.object({ + compositionId: z + .string({ message: "compositionId is required" }) + .min(1, "compositionId cannot be empty"), + inputProps: z.record(z.string(), z.unknown()).default({}), + width: z.number().int().min(1).max(3840).default(720), + height: z.number().int().min(1).max(3840).default(1280), + fps: z.number().int().min(1).max(60).default(30), + durationInFrames: z.number().int().min(1).max(1800).default(240), + codec: z.enum(["h264", "h265", "vp8", "vp9"]).default("h264"), +}); + +/** Inferred type after successful validation. */ +export type RenderVideoBody = z.infer; + +/** + * Validates the request body for POST /api/video/render. + * + * @param body - The raw, parsed JSON body from the request. + * @returns A `NextResponse` with a 400 error if validation fails, + * or the validated `RenderVideoBody` if validation passes. + */ +export function validateRenderVideoBody(body: unknown): NextResponse | RenderVideoBody { + const result = renderVideoBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/trigger/__tests__/triggerRenderVideo.test.ts b/lib/trigger/__tests__/triggerRenderVideo.test.ts new file mode 100644 index 0000000..c829775 --- /dev/null +++ b/lib/trigger/__tests__/triggerRenderVideo.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { triggerRenderVideo } from "@/lib/trigger/triggerRenderVideo"; + +import { tasks } from "@trigger.dev/sdk"; + +vi.mock("@trigger.dev/sdk", () => ({ + tasks: { + trigger: vi.fn(), + }, +})); + +describe("triggerRenderVideo", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("triggers the render-video task with the full payload", async () => { + const mockHandle = { id: "run_render_abc123" }; + vi.mocked(tasks.trigger).mockResolvedValue(mockHandle as never); + + const payload = { + compositionId: "SocialPost", + inputProps: { videoUrl: "https://example.com/video.mp4" }, + width: 720, + height: 1280, + fps: 30, + durationInFrames: 240, + codec: "h264" as const, + accountId: "account-uuid-123", + }; + + const result = await triggerRenderVideo(payload); + + expect(tasks.trigger).toHaveBeenCalledWith("render-video", payload); + expect(result).toEqual(mockHandle); + }); + + it("returns the task handle with runId", async () => { + const mockHandle = { id: "run_xyz789" }; + vi.mocked(tasks.trigger).mockResolvedValue(mockHandle as never); + + const result = await triggerRenderVideo({ + compositionId: "CommitShowcase", + inputProps: {}, + width: 1080, + height: 1080, + fps: 30, + durationInFrames: 300, + codec: "h264", + accountId: "another-account-id", + }); + + expect(result.id).toBe("run_xyz789"); + }); +}); diff --git a/lib/trigger/triggerRenderVideo.ts b/lib/trigger/triggerRenderVideo.ts new file mode 100644 index 0000000..6020998 --- /dev/null +++ b/lib/trigger/triggerRenderVideo.ts @@ -0,0 +1,28 @@ +import { tasks } from "@trigger.dev/sdk"; + +/** Payload sent to the Trigger.dev render-video task. */ +export type RenderVideoPayload = { + compositionId: string; + inputProps: Record; + width: number; + height: number; + fps: number; + durationInFrames: number; + codec: "h264" | "h265" | "vp8" | "vp9"; + accountId: string; +}; + +/** + * Triggers the render-video background task on Trigger.dev. + * + * The task bundles the composition, renders the video, uploads it to + * Supabase storage, and returns the video URL. Poll via + * GET /api/tasks/runs?runId= to check status. + * + * @param payload - Composition config + account info for the render. + * @returns The task handle with runId. + */ +export async function triggerRenderVideo(payload: RenderVideoPayload) { + const handle = await tasks.trigger("render-video", payload); + return handle; +}