From 1ba1849ab2c688bc71d0a83cc7de0bfde5e3cd54 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:44:00 -0400 Subject: [PATCH 1/8] feat: add album-record-store template and dynamic template loading Made-with: Cursor --- src/content/loadTemplate.ts | 14 ++++++++ .../album-record-store/caption-guide.json | 30 ++++++++++++++++ .../references/captions/examples.json | 10 ++++++ .../album-record-store/style-guide.json | 36 +++++++++++++++++++ .../album-record-store/video-moods.json | 10 ++++++ .../album-record-store/video-movements.json | 10 ++++++ src/tasks/createContentTask.ts | 4 +-- 7 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 src/content/templates/album-record-store/caption-guide.json create mode 100644 src/content/templates/album-record-store/references/captions/examples.json create mode 100644 src/content/templates/album-record-store/style-guide.json create mode 100644 src/content/templates/album-record-store/video-moods.json create mode 100644 src/content/templates/album-record-store/video-movements.json diff --git a/src/content/loadTemplate.ts b/src/content/loadTemplate.ts index c01bc3b..53890d1 100644 --- a/src/content/loadTemplate.ts +++ b/src/content/loadTemplate.ts @@ -10,6 +10,10 @@ export interface TemplateData { imagePrompt: string; /** Whether this template uses the artist's face-guide for identity. Defaults to true. */ usesFaceGuide: boolean; + /** Overrides the default face-swap / no-face instruction when set. */ + customInstruction: string | null; + /** Overrides the default person-centric motion prompt when set. */ + customMotionPrompt: string | null; styleGuide: Record | null; captionGuide: Record | null; captionExamples: string[]; @@ -61,11 +65,15 @@ export async function loadTemplate(templateName: string): Promise const imagePrompt = (sg?.imagePrompt as string) ?? ""; // Default to true — most templates use the artist's face const usesFaceGuide = (sg?.usesFaceGuide as boolean) ?? true; + const customInstruction = (sg?.customInstruction as string) ?? null; + const customMotionPrompt = (sg?.customMotionPrompt as string) ?? null; return { name: templateName, imagePrompt, usesFaceGuide, + customInstruction, + customMotionPrompt, styleGuide, captionGuide, captionExamples, @@ -129,6 +137,12 @@ export function buildMotionPrompt(template: TemplateData): string { ? template.videoMoods[Math.floor(Math.random() * template.videoMoods.length)] : ""; + if (template.customMotionPrompt) { + return template.customMotionPrompt + .replace("{movement}", movement) + .replace("{mood}", mood); + } + return `Completely static camera. The person stares at the camera. Movement: ${movement}.${mood ? ` Energy: ${mood}.` : ""} Shot on phone, low light, grainy.`; } diff --git a/src/content/templates/album-record-store/caption-guide.json b/src/content/templates/album-record-store/caption-guide.json new file mode 100644 index 0000000..2d68225 --- /dev/null +++ b/src/content/templates/album-record-store/caption-guide.json @@ -0,0 +1,30 @@ +{ + "templateStyle": "album art on vinyl in a record store — the kind of post an artist makes when their music hits wax for the first time", + "captionRole": "the caption should feel like the artist posted this themselves. proud but not corny. announcing the vinyl, reflecting on the music, or saying something raw about what the album means.", + "tone": "understated pride, like posting a photo of your album in a store and letting the moment speak for itself. not hype-man energy — quiet flex.", + "rules": [ + "lowercase only", + "keep it under 80 characters for short, can go longer for medium/long", + "no punctuation at the end unless its a question mark", + "never sound like a press release or marketing copy", + "never say 'out now' or 'stream now' or 'link in bio'", + "dont describe whats in the image", + "can reference the album, the songs, or what they mean to you", + "can reference the physical vinyl / record store experience", + "if it sounds like a label wrote it, rewrite it until it sounds like the artist texted it to a friend" + ], + "formats": [ + "a one-line reflection on the album ('i left everything in this one')", + "a quiet flex about being on vinyl ('never thought id see this in a store')", + "a nostalgic moment ('used to dig through bins like this looking for something that felt like home')", + "something the listener would screenshot ('this album is the version of me i was scared to show you')", + "a short dedication or thank you that feels real, not performative" + ], + "examples_of_good_length": [ + "i left everything in this one", + "found myself in the crates today", + "this album is the version of me i was scared to show you", + "never thought id see my name on a spine in a record store", + "wrote this in my bedroom now its on wax" + ] +} diff --git a/src/content/templates/album-record-store/references/captions/examples.json b/src/content/templates/album-record-store/references/captions/examples.json new file mode 100644 index 0000000..5d5d383 --- /dev/null +++ b/src/content/templates/album-record-store/references/captions/examples.json @@ -0,0 +1,10 @@ +[ + "i left everything in this one", + "found myself in the crates today", + "never thought id see my name on a spine in a record store", + "wrote this in my bedroom now its on wax", + "this album is the version of me i was scared to show you", + "every scratch on this vinyl is a memory", + "the songs sound different on wax. heavier somehow", + "somebody in new york is gonna find this in a bin one day and feel something" +] diff --git a/src/content/templates/album-record-store/style-guide.json b/src/content/templates/album-record-store/style-guide.json new file mode 100644 index 0000000..3d6eacb --- /dev/null +++ b/src/content/templates/album-record-store/style-guide.json @@ -0,0 +1,36 @@ +{ + "name": "album-record-store", + "description": "Album cover art displayed in a gritty New York record store — vinyl spinning on a turntable", + "usesFaceGuide": true, + "customInstruction": "Place the album cover art from the first image onto a vinyl record sleeve and display it prominently in the scene. The album art should be clearly visible — propped up on a shelf, leaning against a crate, or displayed on the counter next to the turntable. A vinyl record from the same album is spinning on a turntable nearby. Do NOT alter the album art — reproduce it exactly. Remove any text, captions, watermarks, or overlays from the generated scene. The album art itself should retain its original text/design.", + "customMotionPrompt": "Completely static camera. The vinyl record spins slowly on the turntable. {movement} Warm dust-in-the-air feeling. {mood} Shot on phone, warm tungsten lighting.", + "imagePrompt": "A vinyl record spinning on a turntable inside a cramped, rundown New York City record store. The album cover art is displayed next to the turntable, propped against a stack of records. Wooden crate bins full of vinyl records fill the background. Warm tungsten overhead light, dust particles visible in the air. The store feels lived-in — peeling stickers on the counter, handwritten price tags, faded band posters on the walls. Phone camera, slightly warm color cast.", + + "camera": { + "type": "iPhone resting on the counter, recording a quick story", + "angle": "slightly above the turntable, looking down at an angle — like someone held their phone over the record to film it spinning", + "quality": "iPhone video quality — warm color cast from the overhead light, slight lens flare, not perfectly sharp, natural vignetting at corners", + "focus": "turntable and album art in focus, background bins and shelves slightly soft" + }, + + "environment": { + "feel": "a real independent record store in lower Manhattan or Brooklyn — cramped, cluttered, full of character", + "lighting": "warm tungsten bulbs overhead, maybe a small desk lamp near the register. Pools of warm light, deep shadows between the bins. Dust particles catching the light.", + "backgrounds": "wooden crate bins overflowing with vinyl, hand-lettered genre dividers, faded concert posters and stickers on every surface, a boombox or old speakers on a high shelf, maybe a cat sleeping on a stack of records", + "avoid": "clean modern stores, bright fluorescent lighting, empty shelves, corporate branding, pristine surfaces, anything that looks new or staged" + }, + + "subject": { + "expression": "N/A — no person in the shot, the subject is the album and turntable", + "pose": "N/A", + "clothing": "N/A", + "framing": "turntable takes up the lower half of frame, album art visible in the upper portion or to the side, surrounded by the store environment" + }, + + "realism": { + "priority": "this MUST look like a real phone video taken inside an actual NYC record store, not a render or AI image", + "texture": "warm grain from the phone camera, slight dust and scratches visible on the vinyl, wood grain on the crate bins, worn edges on the record sleeves", + "imperfections": "fingerprints on the vinyl, slightly crooked album display, a price sticker on the sleeve, dust on the turntable platter, uneven stacks of records in the background", + "avoid": "clean renders, perfect symmetry, bright even lighting, glossy surfaces, anything that looks digital or AI-generated, stock-photo record stores" + } +} diff --git a/src/content/templates/album-record-store/video-moods.json b/src/content/templates/album-record-store/video-moods.json new file mode 100644 index 0000000..90d7c99 --- /dev/null +++ b/src/content/templates/album-record-store/video-moods.json @@ -0,0 +1,10 @@ +[ + "warm nostalgia, like walking into a place that reminds you of being a kid", + "quiet pride, the feeling of seeing something you made exist in the real world", + "intimate, like youre showing a close friend something that matters to you", + "reverent, the way people handle vinyl carefully because it feels sacred", + "bittersweet, like the album captured a version of you that doesnt exist anymore", + "hypnotic, the kind of calm that comes from watching something spin in circles", + "peaceful solitude, alone in the store after hours", + "wistful, like remembering the sessions that made this album" +] diff --git a/src/content/templates/album-record-store/video-movements.json b/src/content/templates/album-record-store/video-movements.json new file mode 100644 index 0000000..efa9aa9 --- /dev/null +++ b/src/content/templates/album-record-store/video-movements.json @@ -0,0 +1,10 @@ +[ + "the vinyl spins steadily, tonearm tracking the groove, dust particles drift through the warm light", + "camera slowly drifts closer to the album art, the vinyl keeps spinning in the background", + "a hand reaches into frame and gently places the needle on the record", + "the turntable spins, the overhead light flickers once, dust motes float lazily", + "someone flips through records in a crate in the background, out of focus, while the vinyl spins", + "the camera barely moves, just the vinyl spinning and the warm light shifting slightly", + "a slight camera drift to reveal more of the store — bins, posters, clutter — then settles back on the turntable", + "the tonearm rides the groove, a tiny reflection of light glints off the spinning vinyl surface" +] diff --git a/src/tasks/createContentTask.ts b/src/tasks/createContentTask.ts index 5237ab4..bc8e865 100644 --- a/src/tasks/createContentTask.ts +++ b/src/tasks/createContentTask.ts @@ -109,8 +109,8 @@ export const createContentTask = schemaTask({ // --- Step 5: Generate image --- logStep("Generating image"); const referenceImagePath = pickRandomReferenceImage(template); - // Build prompt: face-swap instruction (if needed) + template scene + style guide - const instruction = template.usesFaceGuide ? FACE_SWAP_INSTRUCTION : NO_FACE_INSTRUCTION; + const instruction = template.customInstruction + ?? (template.usesFaceGuide ? FACE_SWAP_INSTRUCTION : NO_FACE_INSTRUCTION); const basePrompt = `${instruction} ${template.imagePrompt}`; const fullPrompt = buildImagePrompt(basePrompt, template.styleGuide); let imageUrl = await generateContentImage({ From dd6f056221755d5488597d05e900c590c586e19d Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:29:45 -0400 Subject: [PATCH 2/8] feat: add optional songs filter to content creation pipeline Allow callers to restrict which songs the pipeline picks from by passing an array of song slugs. When omitted, all songs remain eligible (preserving existing behavior). Made-with: Cursor --- src/content/selectAudioClip.ts | 17 ++++++++++++++++- src/schemas/contentCreationSchema.ts | 2 ++ src/tasks/createContentTask.ts | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/content/selectAudioClip.ts b/src/content/selectAudioClip.ts index 6f223d3..884f4ca 100644 --- a/src/content/selectAudioClip.ts +++ b/src/content/selectAudioClip.ts @@ -48,18 +48,33 @@ export async function selectAudioClip({ artistSlug, clipDuration, lipsync, + songs, }: { githubRepo: string; artistSlug: string; clipDuration: number; lipsync: boolean; + songs?: string[]; }): Promise { // 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 + if (songs && songs.length > 0) { + songPaths = songPaths.filter(path => + songs.some(slug => path.includes(`/songs/${slug}`)), + ); + if (songPaths.length === 0) { + throw new Error( + `None of the specified songs [${songs.join(", ")}] were found for artist ${artistSlug}`, + ); + } + logger.log("Filtered to specified songs", { songs, matchCount: songPaths.length }); + } + // 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/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 bc8e865..517a309 100644 --- a/src/tasks/createContentTask.ts +++ b/src/tasks/createContentTask.ts @@ -95,6 +95,7 @@ export const createContentTask = schemaTask({ artistSlug: payload.artistSlug, clipDuration: DEFAULT_PIPELINE_CONFIG.clipDuration, lipsync: payload.lipsync, + songs: payload.songs, }); // --- Step 4: Fetch artist/audience context --- From eff3a80af1ce4b6b283071e2cec6069d319f77c2 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 30 Mar 2026 17:04:26 -0500 Subject: [PATCH 3/8] refactor: remove unrelated changes, keep only songs[] filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove customInstruction, customMotionPrompt, and album-record-store template — those belong in separate PRs per SRP feedback. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/loadTemplate.ts | 14 -------- .../album-record-store/caption-guide.json | 30 ---------------- .../references/captions/examples.json | 10 ------ .../album-record-store/style-guide.json | 36 ------------------- .../album-record-store/video-moods.json | 10 ------ .../album-record-store/video-movements.json | 10 ------ src/tasks/createContentTask.ts | 4 +-- 7 files changed, 2 insertions(+), 112 deletions(-) delete mode 100644 src/content/templates/album-record-store/caption-guide.json delete mode 100644 src/content/templates/album-record-store/references/captions/examples.json delete mode 100644 src/content/templates/album-record-store/style-guide.json delete mode 100644 src/content/templates/album-record-store/video-moods.json delete mode 100644 src/content/templates/album-record-store/video-movements.json diff --git a/src/content/loadTemplate.ts b/src/content/loadTemplate.ts index 53890d1..c01bc3b 100644 --- a/src/content/loadTemplate.ts +++ b/src/content/loadTemplate.ts @@ -10,10 +10,6 @@ export interface TemplateData { imagePrompt: string; /** Whether this template uses the artist's face-guide for identity. Defaults to true. */ usesFaceGuide: boolean; - /** Overrides the default face-swap / no-face instruction when set. */ - customInstruction: string | null; - /** Overrides the default person-centric motion prompt when set. */ - customMotionPrompt: string | null; styleGuide: Record | null; captionGuide: Record | null; captionExamples: string[]; @@ -65,15 +61,11 @@ export async function loadTemplate(templateName: string): Promise const imagePrompt = (sg?.imagePrompt as string) ?? ""; // Default to true — most templates use the artist's face const usesFaceGuide = (sg?.usesFaceGuide as boolean) ?? true; - const customInstruction = (sg?.customInstruction as string) ?? null; - const customMotionPrompt = (sg?.customMotionPrompt as string) ?? null; return { name: templateName, imagePrompt, usesFaceGuide, - customInstruction, - customMotionPrompt, styleGuide, captionGuide, captionExamples, @@ -137,12 +129,6 @@ export function buildMotionPrompt(template: TemplateData): string { ? template.videoMoods[Math.floor(Math.random() * template.videoMoods.length)] : ""; - if (template.customMotionPrompt) { - return template.customMotionPrompt - .replace("{movement}", movement) - .replace("{mood}", mood); - } - return `Completely static camera. The person stares at the camera. Movement: ${movement}.${mood ? ` Energy: ${mood}.` : ""} Shot on phone, low light, grainy.`; } diff --git a/src/content/templates/album-record-store/caption-guide.json b/src/content/templates/album-record-store/caption-guide.json deleted file mode 100644 index 2d68225..0000000 --- a/src/content/templates/album-record-store/caption-guide.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "templateStyle": "album art on vinyl in a record store — the kind of post an artist makes when their music hits wax for the first time", - "captionRole": "the caption should feel like the artist posted this themselves. proud but not corny. announcing the vinyl, reflecting on the music, or saying something raw about what the album means.", - "tone": "understated pride, like posting a photo of your album in a store and letting the moment speak for itself. not hype-man energy — quiet flex.", - "rules": [ - "lowercase only", - "keep it under 80 characters for short, can go longer for medium/long", - "no punctuation at the end unless its a question mark", - "never sound like a press release or marketing copy", - "never say 'out now' or 'stream now' or 'link in bio'", - "dont describe whats in the image", - "can reference the album, the songs, or what they mean to you", - "can reference the physical vinyl / record store experience", - "if it sounds like a label wrote it, rewrite it until it sounds like the artist texted it to a friend" - ], - "formats": [ - "a one-line reflection on the album ('i left everything in this one')", - "a quiet flex about being on vinyl ('never thought id see this in a store')", - "a nostalgic moment ('used to dig through bins like this looking for something that felt like home')", - "something the listener would screenshot ('this album is the version of me i was scared to show you')", - "a short dedication or thank you that feels real, not performative" - ], - "examples_of_good_length": [ - "i left everything in this one", - "found myself in the crates today", - "this album is the version of me i was scared to show you", - "never thought id see my name on a spine in a record store", - "wrote this in my bedroom now its on wax" - ] -} diff --git a/src/content/templates/album-record-store/references/captions/examples.json b/src/content/templates/album-record-store/references/captions/examples.json deleted file mode 100644 index 5d5d383..0000000 --- a/src/content/templates/album-record-store/references/captions/examples.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - "i left everything in this one", - "found myself in the crates today", - "never thought id see my name on a spine in a record store", - "wrote this in my bedroom now its on wax", - "this album is the version of me i was scared to show you", - "every scratch on this vinyl is a memory", - "the songs sound different on wax. heavier somehow", - "somebody in new york is gonna find this in a bin one day and feel something" -] diff --git a/src/content/templates/album-record-store/style-guide.json b/src/content/templates/album-record-store/style-guide.json deleted file mode 100644 index 3d6eacb..0000000 --- a/src/content/templates/album-record-store/style-guide.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "album-record-store", - "description": "Album cover art displayed in a gritty New York record store — vinyl spinning on a turntable", - "usesFaceGuide": true, - "customInstruction": "Place the album cover art from the first image onto a vinyl record sleeve and display it prominently in the scene. The album art should be clearly visible — propped up on a shelf, leaning against a crate, or displayed on the counter next to the turntable. A vinyl record from the same album is spinning on a turntable nearby. Do NOT alter the album art — reproduce it exactly. Remove any text, captions, watermarks, or overlays from the generated scene. The album art itself should retain its original text/design.", - "customMotionPrompt": "Completely static camera. The vinyl record spins slowly on the turntable. {movement} Warm dust-in-the-air feeling. {mood} Shot on phone, warm tungsten lighting.", - "imagePrompt": "A vinyl record spinning on a turntable inside a cramped, rundown New York City record store. The album cover art is displayed next to the turntable, propped against a stack of records. Wooden crate bins full of vinyl records fill the background. Warm tungsten overhead light, dust particles visible in the air. The store feels lived-in — peeling stickers on the counter, handwritten price tags, faded band posters on the walls. Phone camera, slightly warm color cast.", - - "camera": { - "type": "iPhone resting on the counter, recording a quick story", - "angle": "slightly above the turntable, looking down at an angle — like someone held their phone over the record to film it spinning", - "quality": "iPhone video quality — warm color cast from the overhead light, slight lens flare, not perfectly sharp, natural vignetting at corners", - "focus": "turntable and album art in focus, background bins and shelves slightly soft" - }, - - "environment": { - "feel": "a real independent record store in lower Manhattan or Brooklyn — cramped, cluttered, full of character", - "lighting": "warm tungsten bulbs overhead, maybe a small desk lamp near the register. Pools of warm light, deep shadows between the bins. Dust particles catching the light.", - "backgrounds": "wooden crate bins overflowing with vinyl, hand-lettered genre dividers, faded concert posters and stickers on every surface, a boombox or old speakers on a high shelf, maybe a cat sleeping on a stack of records", - "avoid": "clean modern stores, bright fluorescent lighting, empty shelves, corporate branding, pristine surfaces, anything that looks new or staged" - }, - - "subject": { - "expression": "N/A — no person in the shot, the subject is the album and turntable", - "pose": "N/A", - "clothing": "N/A", - "framing": "turntable takes up the lower half of frame, album art visible in the upper portion or to the side, surrounded by the store environment" - }, - - "realism": { - "priority": "this MUST look like a real phone video taken inside an actual NYC record store, not a render or AI image", - "texture": "warm grain from the phone camera, slight dust and scratches visible on the vinyl, wood grain on the crate bins, worn edges on the record sleeves", - "imperfections": "fingerprints on the vinyl, slightly crooked album display, a price sticker on the sleeve, dust on the turntable platter, uneven stacks of records in the background", - "avoid": "clean renders, perfect symmetry, bright even lighting, glossy surfaces, anything that looks digital or AI-generated, stock-photo record stores" - } -} diff --git a/src/content/templates/album-record-store/video-moods.json b/src/content/templates/album-record-store/video-moods.json deleted file mode 100644 index 90d7c99..0000000 --- a/src/content/templates/album-record-store/video-moods.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - "warm nostalgia, like walking into a place that reminds you of being a kid", - "quiet pride, the feeling of seeing something you made exist in the real world", - "intimate, like youre showing a close friend something that matters to you", - "reverent, the way people handle vinyl carefully because it feels sacred", - "bittersweet, like the album captured a version of you that doesnt exist anymore", - "hypnotic, the kind of calm that comes from watching something spin in circles", - "peaceful solitude, alone in the store after hours", - "wistful, like remembering the sessions that made this album" -] diff --git a/src/content/templates/album-record-store/video-movements.json b/src/content/templates/album-record-store/video-movements.json deleted file mode 100644 index efa9aa9..0000000 --- a/src/content/templates/album-record-store/video-movements.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - "the vinyl spins steadily, tonearm tracking the groove, dust particles drift through the warm light", - "camera slowly drifts closer to the album art, the vinyl keeps spinning in the background", - "a hand reaches into frame and gently places the needle on the record", - "the turntable spins, the overhead light flickers once, dust motes float lazily", - "someone flips through records in a crate in the background, out of focus, while the vinyl spins", - "the camera barely moves, just the vinyl spinning and the warm light shifting slightly", - "a slight camera drift to reveal more of the store — bins, posters, clutter — then settles back on the turntable", - "the tonearm rides the groove, a tiny reflection of light glints off the spinning vinyl surface" -] diff --git a/src/tasks/createContentTask.ts b/src/tasks/createContentTask.ts index 517a309..01ccdc8 100644 --- a/src/tasks/createContentTask.ts +++ b/src/tasks/createContentTask.ts @@ -110,8 +110,8 @@ export const createContentTask = schemaTask({ // --- Step 5: Generate image --- logStep("Generating image"); const referenceImagePath = pickRandomReferenceImage(template); - const instruction = template.customInstruction - ?? (template.usesFaceGuide ? FACE_SWAP_INSTRUCTION : NO_FACE_INSTRUCTION); + // Build prompt: face-swap instruction (if needed) + template scene + style guide + const instruction = template.usesFaceGuide ? FACE_SWAP_INSTRUCTION : NO_FACE_INSTRUCTION; const basePrompt = `${instruction} ${template.imagePrompt}`; const fullPrompt = buildImagePrompt(basePrompt, template.styleGuide); let imageUrl = await generateContentImage({ From 62bb3df4de02b064452f5215356e4d85327eb3d7 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 30 Mar 2026 17:08:23 -0500 Subject: [PATCH 4/8] refactor: extract filterSongPaths into separate file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Open/Closed Principle feedback — song filtering is now a standalone function with exact slug matching (fixes substring false positives like "ad" matching "adhd"). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/filterSongPaths.ts | 34 ++++++++++++++++++++++++++++++++++ src/content/selectAudioClip.ts | 11 ++--------- 2 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 src/content/filterSongPaths.ts diff --git a/src/content/filterSongPaths.ts b/src/content/filterSongPaths.ts new file mode 100644 index 0000000..887594d --- /dev/null +++ b/src/content/filterSongPaths.ts @@ -0,0 +1,34 @@ +import { logger } from "@trigger.dev/sdk/v3"; +import { parseSongPath } from "./listArtistSongs"; + +/** + * Filters song paths to only include those matching the given slugs. + * Uses exact slug matching against the directory name under /songs/. + * + * @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[], + artistSlug: string, +): string[] { + 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}`, + ); + } + + logger.log("Filtered to specified songs", { songs, matchCount: filtered.length }); + return filtered; +} diff --git a/src/content/selectAudioClip.ts b/src/content/selectAudioClip.ts index 884f4ca..7835572 100644 --- a/src/content/selectAudioClip.ts +++ b/src/content/selectAudioClip.ts @@ -1,5 +1,6 @@ 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"; @@ -64,15 +65,7 @@ export async function selectAudioClip({ // Step 1b: Filter to allowed songs if specified if (songs && songs.length > 0) { - songPaths = songPaths.filter(path => - songs.some(slug => path.includes(`/songs/${slug}`)), - ); - if (songPaths.length === 0) { - throw new Error( - `None of the specified songs [${songs.join(", ")}] were found for artist ${artistSlug}`, - ); - } - logger.log("Filtered to specified songs", { songs, matchCount: songPaths.length }); + songPaths = filterSongPaths(songPaths, songs, artistSlug); } // Step 2: Pick a random song From 33c889cd1c271b9c01959ed54454218c3707f645 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 30 Mar 2026 17:09:47 -0500 Subject: [PATCH 5/8] test: add filterSongPaths tests Covers exact slug matching, case insensitivity, whitespace trimming, org repo encoded paths, and error on no matches. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/__tests__/filterSongPaths.test.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/content/__tests__/filterSongPaths.test.ts diff --git a/src/content/__tests__/filterSongPaths.test.ts b/src/content/__tests__/filterSongPaths.test.ts new file mode 100644 index 0000000..239e1e7 --- /dev/null +++ b/src/content/__tests__/filterSongPaths.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from "vitest"; +import { filterSongPaths } from "../filterSongPaths"; + +vi.mock("@trigger.dev/sdk/v3", () => ({ + logger: { log: 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("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]]); + }); +}); From 5ffc9ebafeaf3c119f1d72011826c8059489d755 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 30 Mar 2026 17:10:41 -0500 Subject: [PATCH 6/8] refactor: selectAudioClip accepts payload directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoids destructuring payload fields at the call site — the task file no longer needs updating when audio-related schema fields are added. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/selectAudioClip.ts | 19 ++++++------------- src/tasks/createContentTask.ts | 11 ++++------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/content/selectAudioClip.ts b/src/content/selectAudioClip.ts index 7835572..accf42e 100644 --- a/src/content/selectAudioClip.ts +++ b/src/content/selectAudioClip.ts @@ -5,6 +5,7 @@ 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"; export interface SelectedAudioClip { /** Original song filename */ @@ -44,19 +45,11 @@ 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, - songs, -}: { - githubRepo: string; - artistSlug: string; - clipDuration: number; - lipsync: boolean; - songs?: string[]; -}): Promise { +export async function selectAudioClip( + payload: Pick, + clipDuration: number, +): Promise { + const { githubRepo, artistSlug, lipsync, songs } = payload; // Step 1: List available songs let songPaths = await listArtistSongs(githubRepo, artistSlug); if (songPaths.length === 0) { diff --git a/src/tasks/createContentTask.ts b/src/tasks/createContentTask.ts index 01ccdc8..16c4f5b 100644 --- a/src/tasks/createContentTask.ts +++ b/src/tasks/createContentTask.ts @@ -90,13 +90,10 @@ 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, - songs: payload.songs, - }); + const audioClip = await selectAudioClip( + payload, + DEFAULT_PIPELINE_CONFIG.clipDuration, + ); // --- Step 4: Fetch artist/audience context --- logStep("Fetching artist context"); From 31dd7ad349b4a2cfe183db94cbb4d6f22281c982 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 30 Mar 2026 17:27:09 -0500 Subject: [PATCH 7/8] refactor: simplify selectAudioClip signature and filter guard - clipDuration now sourced from DEFAULT_PIPELINE_CONFIG internally - filterSongPaths handles undefined/empty songs as no-op, removing the if guard at the call site - Added tests for undefined and empty songs cases Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/__tests__/filterSongPaths.test.ts | 21 +++++++++++++++++++ src/content/filterSongPaths.ts | 5 ++++- src/content/selectAudioClip.ts | 7 +++---- src/content/testPipeline.ts | 3 --- src/tasks/createContentTask.ts | 6 +----- 5 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/content/__tests__/filterSongPaths.test.ts b/src/content/__tests__/filterSongPaths.test.ts index 239e1e7..be13fd2 100644 --- a/src/content/__tests__/filterSongPaths.test.ts +++ b/src/content/__tests__/filterSongPaths.test.ts @@ -70,6 +70,27 @@ describe("filterSongPaths", () => { ]); }); + 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", diff --git a/src/content/filterSongPaths.ts b/src/content/filterSongPaths.ts index 887594d..35bc7ef 100644 --- a/src/content/filterSongPaths.ts +++ b/src/content/filterSongPaths.ts @@ -4,6 +4,7 @@ import { parseSongPath } from "./listArtistSongs"; /** * 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"]) @@ -12,9 +13,11 @@ import { parseSongPath } from "./listArtistSongs"; */ export function filterSongPaths( songPaths: string[], - songs: 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 => { diff --git a/src/content/selectAudioClip.ts b/src/content/selectAudioClip.ts index accf42e..5825022 100644 --- a/src/content/selectAudioClip.ts +++ b/src/content/selectAudioClip.ts @@ -6,6 +6,7 @@ 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 */ @@ -47,9 +48,9 @@ export interface SelectedAudioClip { */ export async function selectAudioClip( payload: Pick, - clipDuration: number, ): Promise { const { githubRepo, artistSlug, lipsync, songs } = payload; + const clipDuration = DEFAULT_PIPELINE_CONFIG.clipDuration; // Step 1: List available songs let songPaths = await listArtistSongs(githubRepo, artistSlug); if (songPaths.length === 0) { @@ -57,9 +58,7 @@ export async function selectAudioClip( } // Step 1b: Filter to allowed songs if specified - if (songs && songs.length > 0) { - songPaths = filterSongPaths(songPaths, songs, artistSlug); - } + songPaths = filterSongPaths(songPaths, songs, artistSlug); // Step 2: Pick a random song const encodedPath = songPaths[Math.floor(Math.random() * songPaths.length)]; 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/tasks/createContentTask.ts b/src/tasks/createContentTask.ts index 16c4f5b..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,10 +89,7 @@ export const createContentTask = schemaTask({ // --- Step 3: Select audio clip --- logStep("Selecting audio clip"); - const audioClip = await selectAudioClip( - payload, - DEFAULT_PIPELINE_CONFIG.clipDuration, - ); + const audioClip = await selectAudioClip(payload); // --- Step 4: Fetch artist/audience context --- logStep("Fetching artist context"); From c3f2fc4e7192ca7153b097bb2e3cdbd5ac99919c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 30 Mar 2026 17:29:37 -0500 Subject: [PATCH 8/8] refactor: use shared logStep in filterSongPaths Replace logger.log with logStep for consistent observability. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content/__tests__/filterSongPaths.test.ts | 4 ++-- src/content/filterSongPaths.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/content/__tests__/filterSongPaths.test.ts b/src/content/__tests__/filterSongPaths.test.ts index be13fd2..4de10bd 100644 --- a/src/content/__tests__/filterSongPaths.test.ts +++ b/src/content/__tests__/filterSongPaths.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi } from "vitest"; import { filterSongPaths } from "../filterSongPaths"; -vi.mock("@trigger.dev/sdk/v3", () => ({ - logger: { log: vi.fn() }, +vi.mock("../../sandboxes/logStep", () => ({ + logStep: vi.fn(), })); describe("filterSongPaths", () => { diff --git a/src/content/filterSongPaths.ts b/src/content/filterSongPaths.ts index 35bc7ef..ada533a 100644 --- a/src/content/filterSongPaths.ts +++ b/src/content/filterSongPaths.ts @@ -1,5 +1,5 @@ -import { logger } from "@trigger.dev/sdk/v3"; import { parseSongPath } from "./listArtistSongs"; +import { logStep } from "../sandboxes/logStep"; /** * Filters song paths to only include those matching the given slugs. @@ -32,6 +32,6 @@ export function filterSongPaths( ); } - logger.log("Filtered to specified songs", { songs, matchCount: filtered.length }); + logStep("Filtered to specified songs", false, { songs, matchCount: filtered.length }); return filtered; }