diff --git a/src/content/__tests__/filterSongPaths.test.ts b/src/content/__tests__/filterSongPaths.test.ts new file mode 100644 index 0000000..4de10bd --- /dev/null +++ b/src/content/__tests__/filterSongPaths.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from "vitest"; +import { filterSongPaths } from "../filterSongPaths"; + +vi.mock("../../sandboxes/logStep", () => ({ + logStep: vi.fn(), +})); + +describe("filterSongPaths", () => { + const artistSlug = "gatsby-grace"; + + it("keeps only paths matching the requested slugs", () => { + const songPaths = [ + "artists/gatsby-grace/songs/hiccups/hiccups.mp3", + "artists/gatsby-grace/songs/adhd/adhd.mp3", + "artists/gatsby-grace/songs/freefall/freefall.mp3", + ]; + + const result = filterSongPaths(songPaths, ["hiccups", "adhd"], artistSlug); + + expect(result).toEqual([ + "artists/gatsby-grace/songs/hiccups/hiccups.mp3", + "artists/gatsby-grace/songs/adhd/adhd.mp3", + ]); + }); + + it("uses exact matching — 'ad' does not match 'adhd'", () => { + const songPaths = [ + "artists/gatsby-grace/songs/adhd/adhd.mp3", + "artists/gatsby-grace/songs/ad/ad.mp3", + ]; + + const result = filterSongPaths(songPaths, ["ad"], artistSlug); + + expect(result).toEqual([ + "artists/gatsby-grace/songs/ad/ad.mp3", + ]); + }); + + it("throws when no songs match", () => { + const songPaths = [ + "artists/gatsby-grace/songs/hiccups/hiccups.mp3", + ]; + + expect(() => filterSongPaths(songPaths, ["nonexistent"], artistSlug)).toThrow( + "None of the specified songs [nonexistent] were found for artist gatsby-grace", + ); + }); + + it("handles case-insensitive slug matching", () => { + const songPaths = [ + "artists/gatsby-grace/songs/Hiccups/Hiccups.mp3", + ]; + + const result = filterSongPaths(songPaths, ["hiccups"], artistSlug); + + expect(result).toEqual([ + "artists/gatsby-grace/songs/Hiccups/Hiccups.mp3", + ]); + }); + + it("trims whitespace from slugs", () => { + const songPaths = [ + "artists/gatsby-grace/songs/hiccups/hiccups.mp3", + ]; + + const result = filterSongPaths(songPaths, [" hiccups "], artistSlug); + + expect(result).toEqual([ + "artists/gatsby-grace/songs/hiccups/hiccups.mp3", + ]); + }); + + it("returns all paths when songs is undefined", () => { + const songPaths = [ + "artists/gatsby-grace/songs/hiccups/hiccups.mp3", + "artists/gatsby-grace/songs/adhd/adhd.mp3", + ]; + + const result = filterSongPaths(songPaths, undefined, artistSlug); + + expect(result).toEqual(songPaths); + }); + + it("returns all paths when songs is empty", () => { + const songPaths = [ + "artists/gatsby-grace/songs/hiccups/hiccups.mp3", + ]; + + const result = filterSongPaths(songPaths, [], artistSlug); + + expect(result).toEqual(songPaths); + }); + + it("works with org repo encoded paths", () => { + const songPaths = [ + "__ORG_REPO__https://github.com/org/repo__artists/gatsby-grace/songs/hiccups/hiccups.mp3", + ]; + + const result = filterSongPaths(songPaths, ["hiccups"], artistSlug); + + expect(result).toEqual([songPaths[0]]); + }); +}); diff --git a/src/content/filterSongPaths.ts b/src/content/filterSongPaths.ts new file mode 100644 index 0000000..ada533a --- /dev/null +++ b/src/content/filterSongPaths.ts @@ -0,0 +1,37 @@ +import { parseSongPath } from "./listArtistSongs"; +import { logStep } from "../sandboxes/logStep"; + +/** + * Filters song paths to only include those matching the given slugs. + * Uses exact slug matching against the directory name under /songs/. + * Returns songPaths unmodified when songs is empty or undefined. + * + * @param songPaths - Encoded song paths from listArtistSongs + * @param songs - Song slugs to keep (e.g. ["hiccups", "adhd"]) + * @param artistSlug - Artist slug (for error messages) + * @returns Filtered song paths + */ +export function filterSongPaths( + songPaths: string[], + songs: string[] | undefined, + artistSlug: string, +): string[] { + if (!songs || songs.length === 0) return songPaths; + + const requested = new Set(songs.map(s => s.trim().toLowerCase()).filter(Boolean)); + + const filtered = songPaths.filter(encodedPath => { + const { filePath } = parseSongPath(encodedPath); + const match = filePath.match(/\/songs\/([^/]+)\//); + return !!match && requested.has(match[1].toLowerCase()); + }); + + if (filtered.length === 0) { + throw new Error( + `None of the specified songs [${songs.join(", ")}] were found for artist ${artistSlug}`, + ); + } + + logStep("Filtered to specified songs", false, { songs, matchCount: filtered.length }); + return filtered; +} diff --git a/src/content/selectAudioClip.ts b/src/content/selectAudioClip.ts index 6f223d3..5825022 100644 --- a/src/content/selectAudioClip.ts +++ b/src/content/selectAudioClip.ts @@ -1,9 +1,12 @@ import { logger } from "@trigger.dev/sdk/v3"; import { listArtistSongs, parseSongPath } from "./listArtistSongs"; +import { filterSongPaths } from "./filterSongPaths"; import { fetchGithubFile } from "./fetchGithubFile"; import { transcribeSong } from "./transcribeSong"; import { analyzeClips, type SongClip } from "./analyzeClips"; import type { SongLyrics } from "./transcribeSong"; +import type { CreateContentPayload } from "../schemas/contentCreationSchema"; +import { DEFAULT_PIPELINE_CONFIG } from "./defaultPipelineConfig"; export interface SelectedAudioClip { /** Original song filename */ @@ -43,23 +46,20 @@ export interface SelectedAudioClip { * @param lipsync - Whether to prefer clips with lyrics (for lipsync mode) * @returns Selected audio clip with all metadata */ -export async function selectAudioClip({ - githubRepo, - artistSlug, - clipDuration, - lipsync, -}: { - githubRepo: string; - artistSlug: string; - clipDuration: number; - lipsync: boolean; -}): Promise { +export async function selectAudioClip( + payload: Pick, +): Promise { + const { githubRepo, artistSlug, lipsync, songs } = payload; + const clipDuration = DEFAULT_PIPELINE_CONFIG.clipDuration; // Step 1: List available songs - const songPaths = await listArtistSongs(githubRepo, artistSlug); + let songPaths = await listArtistSongs(githubRepo, artistSlug); if (songPaths.length === 0) { throw new Error(`No mp3 files found for artist ${artistSlug}`); } + // Step 1b: Filter to allowed songs if specified + songPaths = filterSongPaths(songPaths, songs, artistSlug); + // Step 2: Pick a random song const encodedPath = songPaths[Math.floor(Math.random() * songPaths.length)]; const { repoUrl, filePath: songPath } = parseSongPath(encodedPath); diff --git a/src/content/testPipeline.ts b/src/content/testPipeline.ts index c6dbf63..af3dd32 100644 --- a/src/content/testPipeline.ts +++ b/src/content/testPipeline.ts @@ -175,15 +175,12 @@ async function testUpscaleVideo(): Promise { async function testAudio(): Promise { setupFal(); const { selectAudioClip } = await import("./selectAudioClip.js"); - const { DEFAULT_PIPELINE_CONFIG } = await import("./defaultPipelineConfig.js"); - console.log("\nšŸŽµ Testing: Audio Selection\n"); console.log(" šŸ”„ Finding songs, transcribing, analyzing...\n"); const clip = await selectAudioClip({ githubRepo: GITHUB_REPO, artistSlug: ARTIST_SLUG, - clipDuration: DEFAULT_PIPELINE_CONFIG.clipDuration, lipsync: false, }); diff --git a/src/schemas/contentCreationSchema.ts b/src/schemas/contentCreationSchema.ts index b733ca1..9d31eaa 100644 --- a/src/schemas/contentCreationSchema.ts +++ b/src/schemas/contentCreationSchema.ts @@ -14,6 +14,8 @@ export const createContentPayloadSchema = z.object({ upscale: z.boolean().default(false), /** GitHub repo URL so the task can fetch artist files (face-guide, songs). */ githubRepo: z.string().url("githubRepo must be a valid URL"), + /** Optional list of song slugs to restrict which songs the pipeline picks from. */ + songs: z.array(z.string()).optional(), }); export type CreateContentPayload = z.infer; diff --git a/src/tasks/createContentTask.ts b/src/tasks/createContentTask.ts index 5237ab4..ba30ffe 100644 --- a/src/tasks/createContentTask.ts +++ b/src/tasks/createContentTask.ts @@ -13,7 +13,6 @@ import { generateCaption } from "../content/generateCaption"; import { fetchArtistContext } from "../content/fetchArtistContext"; import { fetchAudienceContext } from "../content/fetchAudienceContext"; import { renderFinalVideo } from "../content/renderFinalVideo"; -import { DEFAULT_PIPELINE_CONFIG } from "../content/defaultPipelineConfig"; import { loadTemplate, pickRandomReferenceImage, @@ -90,12 +89,7 @@ export const createContentTask = schemaTask({ // --- Step 3: Select audio clip --- logStep("Selecting audio clip"); - const audioClip = await selectAudioClip({ - githubRepo: payload.githubRepo, - artistSlug: payload.artistSlug, - clipDuration: DEFAULT_PIPELINE_CONFIG.clipDuration, - lipsync: payload.lipsync, - }); + const audioClip = await selectAudioClip(payload); // --- Step 4: Fetch artist/audience context --- logStep("Fetching artist context");