From fe3784d370948401db6ea0d73fcd74493023a660 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Tue, 14 Apr 2026 20:27:54 +0000 Subject: [PATCH] feat: add dsp logo overlay to content pipeline (REC-63) Add DSP logo support to the video rendering pipeline. When a dsp value ("spotify" or "apple") is provided, the corresponding logo URL is appended to overlay images for the final ffmpeg render. - Add DSP_VALUES and dsp field to content creation schema - Add resolveDspLogoUrl utility with placeholder URLs - Integrate DSP logo into createContentTask overlay logic - Add 5 new tests for DSP resolution and pipeline integration Co-Authored-By: Paperclip --- .../__tests__/resolveDspLogoUrl.test.ts | 20 +++++++++++++++++++ src/content/resolveDspLogoUrl.ts | 16 +++++++++++++++ src/schemas/contentCreationSchema.ts | 5 +++++ src/tasks/__tests__/createContentTask.test.ts | 19 ++++++++++++++++++ src/tasks/createContentTask.ts | 7 ++++++- 5 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/content/__tests__/resolveDspLogoUrl.test.ts create mode 100644 src/content/resolveDspLogoUrl.ts diff --git a/src/content/__tests__/resolveDspLogoUrl.test.ts b/src/content/__tests__/resolveDspLogoUrl.test.ts new file mode 100644 index 0000000..c722f8d --- /dev/null +++ b/src/content/__tests__/resolveDspLogoUrl.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { resolveDspLogoUrl } from "../resolveDspLogoUrl"; + +describe("resolveDspLogoUrl", () => { + it("returns null for 'none'", () => { + expect(resolveDspLogoUrl("none")).toBeNull(); + }); + + it("returns a URL for 'spotify'", () => { + const url = resolveDspLogoUrl("spotify"); + expect(url).toContain("spotify"); + expect(url).toMatch(/^https?:\/\//); + }); + + it("returns a URL for 'apple'", () => { + const url = resolveDspLogoUrl("apple"); + expect(url).toContain("apple"); + expect(url).toMatch(/^https?:\/\//); + }); +}); diff --git a/src/content/resolveDspLogoUrl.ts b/src/content/resolveDspLogoUrl.ts new file mode 100644 index 0000000..76f45c0 --- /dev/null +++ b/src/content/resolveDspLogoUrl.ts @@ -0,0 +1,16 @@ +import type { DspValue } from "../schemas/contentCreationSchema"; + +const DSP_LOGO_URLS: Record, string> = { + spotify: "https://recoup-api.vercel.app/api/assets/dsp-logo-spotify.png", + apple: "https://recoup-api.vercel.app/api/assets/dsp-logo-apple.png", +}; + +/** + * Returns the DSP logo URL for the given DSP value, or null if "none". + * + * @param dsp - The DSP value from the content creation payload. + */ +export function resolveDspLogoUrl(dsp: DspValue): string | null { + if (dsp === "none") return null; + return DSP_LOGO_URLS[dsp]; +} diff --git a/src/schemas/contentCreationSchema.ts b/src/schemas/contentCreationSchema.ts index bed1583..309c93a 100644 --- a/src/schemas/contentCreationSchema.ts +++ b/src/schemas/contentCreationSchema.ts @@ -3,6 +3,9 @@ import { z } from "zod"; export const CAPTION_LENGTHS = ["none", "short", "medium", "long"] as const; export type CaptionLength = (typeof CAPTION_LENGTHS)[number]; +export const DSP_VALUES = ["none", "spotify", "apple"] as const; +export type DspValue = (typeof DSP_VALUES)[number]; + export const createContentPayloadSchema = z.object({ accountId: z.string().min(1, "accountId is required"), artistSlug: z.string().min(1, "artistSlug is required"), @@ -10,6 +13,8 @@ export const createContentPayloadSchema = z.object({ lipsync: z.boolean().default(false), /** Controls caption text length: "none" skips captions, "short" (1-2 lines), "medium" (3-5 lines), "long" (paragraph). */ captionLength: z.enum(CAPTION_LENGTHS).default("none"), + /** Which DSP logo to overlay on the video: "none" (no logo), "spotify", or "apple". */ + dsp: z.enum(DSP_VALUES).default("none"), /** Whether to upscale the image and video for higher quality. Adds ~2 min to pipeline. */ upscale: z.boolean().default(false), /** GitHub repo URL so the task can fetch artist files (face-guide, songs). */ diff --git a/src/tasks/__tests__/createContentTask.test.ts b/src/tasks/__tests__/createContentTask.test.ts index cf33939..3f8eb7c 100644 --- a/src/tasks/__tests__/createContentTask.test.ts +++ b/src/tasks/__tests__/createContentTask.test.ts @@ -171,6 +171,25 @@ describe("createContentTask", () => { expect(result.captionText).toBe(""); }); + it("includes DSP logo URL in overlay images when dsp is set", async () => { + await mockRun({ ...VALID_PAYLOAD, dsp: "spotify" }); + + const { renderFinalVideo } = await import("../../content/renderFinalVideo"); + const callArgs = vi.mocked(renderFinalVideo).mock.calls[0][0] as Record; + const overlayUrls = callArgs.overlayImageUrls as string[]; + expect(overlayUrls).toBeDefined(); + expect(overlayUrls.some((url: string) => url.includes("spotify"))).toBe(true); + }); + + it("does not include DSP logo when dsp is none", async () => { + await mockRun({ ...VALID_PAYLOAD, dsp: "none" }); + + const { renderFinalVideo } = await import("../../content/renderFinalVideo"); + const callArgs = vi.mocked(renderFinalVideo).mock.calls[0][0] as Record; + // Template doesn't have usesImageOverlay, so overlayImageUrls should be undefined + expect(callArgs.overlayImageUrls).toBeUndefined(); + }); + it("throws when FAL_KEY is missing", async () => { delete process.env.FAL_KEY; await expect(mockRun(VALID_PAYLOAD)).rejects.toThrow("FAL_KEY"); diff --git a/src/tasks/createContentTask.ts b/src/tasks/createContentTask.ts index cad1e64..f6557ea 100644 --- a/src/tasks/createContentTask.ts +++ b/src/tasks/createContentTask.ts @@ -15,6 +15,7 @@ import { generateVideo, } from "../recoup/contentApi"; import { resolveCaptionText } from "../content/resolveCaptionText"; +import { resolveDspLogoUrl } from "../content/resolveDspLogoUrl"; /** * Content-creation task — full pipeline that generates a social-ready video. @@ -133,6 +134,10 @@ export const createContentTask = schemaTask({ // --- Step 10: Final render (ffmpeg) --- logStep("Rendering final video (ffmpeg)"); + const dspLogoUrl = resolveDspLogoUrl(payload.dsp); + const overlayUrls = template.usesImageOverlay + ? [...additionalImageUrls, ...(dspLogoUrl ? [dspLogoUrl] : [])] + : dspLogoUrl ? [dspLogoUrl] : undefined; const finalVideo = await renderFinalVideo({ videoUrl, songBuffer: audioClip.songBuffer, @@ -140,7 +145,7 @@ export const createContentTask = schemaTask({ audioDurationSeconds: audioClip.durationSeconds, captionText, hasAudio: payload.lipsync, - overlayImageUrls: template.usesImageOverlay ? additionalImageUrls : undefined, + overlayImageUrls: overlayUrls, }); // --- Return result ---