diff --git a/lib/agents/content/__tests__/postVideoResults.test.ts b/lib/agents/content/__tests__/postVideoResults.test.ts index 33ba88c91..647d541dc 100644 --- a/lib/agents/content/__tests__/postVideoResults.test.ts +++ b/lib/agents/content/__tests__/postVideoResults.test.ts @@ -114,4 +114,82 @@ describe("postVideoResults", () => { }), ); }); + + it("posts static image before video when imageUrl is present", async () => { + const imgBuf = Buffer.from([0x10]); + const vidBuf = Buffer.from([0x20]); + mockedDownload + .mockResolvedValueOnce(imgBuf) // image download + .mockResolvedValueOnce(vidBuf); // video download + + const videos = [ + { + runId: "r1", + status: "completed" as const, + videoUrl: "https://cdn.example.com/v.mp4", + imageUrl: "https://cdn.example.com/static.png", + }, + ]; + + await postVideoResults(thread as never, videos, 0); + + expect(thread.post).toHaveBeenCalledTimes(2); + + // First call: image + expect(thread.post).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + markdown: "**Editorial Image**", + files: [expect.objectContaining({ mimeType: "image/png" })], + }), + ); + + // Second call: video + expect(thread.post).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + files: [expect.objectContaining({ mimeType: "video/mp4" })], + }), + ); + }); + + it("falls back to URL text when image download fails", async () => { + mockedDownload + .mockResolvedValueOnce(null) // image download fails + .mockResolvedValueOnce(Buffer.from([0x20])); // video download + + const videos = [ + { + runId: "r1", + status: "completed" as const, + videoUrl: "https://cdn.example.com/v.mp4", + imageUrl: "https://cdn.example.com/static.png", + }, + ]; + + await postVideoResults(thread as never, videos, 0); + + expect(thread.post).toHaveBeenNthCalledWith( + 1, + expect.stringContaining("https://cdn.example.com/static.png"), + ); + }); + + it("skips image posting when no imageUrl is present", 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); + + // Only the video post, no image + expect(thread.post).toHaveBeenCalledTimes(1); + expect(thread.post).toHaveBeenCalledWith( + expect.objectContaining({ + files: [expect.objectContaining({ mimeType: "video/mp4" })], + }), + ); + }); }); diff --git a/lib/agents/content/postVideoResults.ts b/lib/agents/content/postVideoResults.ts index 36bd9c13e..7bbe9bf0a 100644 --- a/lib/agents/content/postVideoResults.ts +++ b/lib/agents/content/postVideoResults.ts @@ -5,6 +5,7 @@ interface VideoResult { runId: string; status: string; videoUrl?: string; + imageUrl?: string; captionText?: string; } @@ -17,11 +18,11 @@ interface Thread { } /** - * Downloads completed videos in parallel and posts each to the thread. + * Downloads completed videos and static images 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 videos - Array of completed video results (may also contain imageUrl) * @param failedCount - Number of failed runs to report */ export async function postVideoResults( @@ -29,13 +30,42 @@ export async function postVideoResults( videos: VideoResult[], failedCount: number, ): Promise { - // Download all videos in parallel - const buffers = await Promise.all(videos.map(v => downloadVideoBuffer(v.videoUrl!))); + // Collect all URLs to download in parallel + const imageUrls = videos.map(v => v.imageUrl).filter(Boolean) as string[]; + const videoUrls = videos.map(v => v.videoUrl!); + + const [imageBuffers, videoBuffers] = await Promise.all([ + Promise.all(imageUrls.map(url => downloadVideoBuffer(url))), + Promise.all(videoUrls.map(url => downloadVideoBuffer(url))), + ]); + + // Post static images first (one per result that has imageUrl) + let imgIdx = 0; + for (const v of videos) { + if (!v.imageUrl) continue; + const imageBuffer = imageBuffers[imgIdx++]; + + if (imageBuffer) { + const filename = getFilenameFromUrl(v.imageUrl); + await thread.post({ + markdown: "**Editorial Image**", + files: [ + { + data: imageBuffer, + filename, + mimeType: "image/png", + }, + ], + }); + } else { + await thread.post(`**Editorial Image:** ${v.imageUrl}`); + } + } // Post each video sequentially (Slack ordering matters) for (let i = 0; i < videos.length; i++) { const v = videos[i]; - const videoBuffer = buffers[i]; + const videoBuffer = videoBuffers[i]; if (videoBuffer) { const filename = getFilenameFromUrl(v.videoUrl!); diff --git a/lib/agents/content/validateContentAgentCallback.ts b/lib/agents/content/validateContentAgentCallback.ts index 70fff8f99..adc238607 100644 --- a/lib/agents/content/validateContentAgentCallback.ts +++ b/lib/agents/content/validateContentAgentCallback.ts @@ -6,6 +6,7 @@ const contentRunResultSchema = z.object({ runId: z.string(), status: z.enum(["completed", "failed", "timeout"]), videoUrl: z.string().optional(), + imageUrl: z.string().optional(), captionText: z.string().optional(), error: z.string().optional(), });