Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions src/artists/getBatchArtistSocials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,20 @@ import { getArtistSocials } from "../recoup/getArtistSocials";
/**
* Fetches socials for all artists in parallel.
* Returns a Map of artistId -> socials array.
*
* @param artistIds
*/
export async function getBatchArtistSocials(
artistIds: string[]
artistIds: string[],
): Promise<Map<string, Awaited<ReturnType<typeof getArtistSocials>>>> {
logger.log("Fetching socials for all artists", {
totalArtists: artistIds.length,
});

const socialsResponses = await Promise.all(
artistIds.map((artistId) => getArtistSocials(artistId))
);
const socialsResponses = await Promise.all(artistIds.map(artistId => getArtistSocials(artistId)));

// Store socials in map
const artistSocialsMap = new Map<
string,
Awaited<ReturnType<typeof getArtistSocials>>
>();
const artistSocialsMap = new Map<string, Awaited<ReturnType<typeof getArtistSocials>>>();

for (let i = 0; i < artistIds.length; i++) {
artistSocialsMap.set(artistIds[i], socialsResponses[i]);
Expand Down
4 changes: 3 additions & 1 deletion src/artists/isScrapableSocial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type { ArtistSocialProfile } from "../recoup/getArtistSocials";
*
* Non-scrapable platforms:
* - open.spotify.com
*
* @param social
*/
export function isScrapableSocial(social: ArtistSocialProfile): boolean {
const profileUrl = social.profile_url.toLowerCase();
Expand Down Expand Up @@ -39,5 +41,5 @@ export function isScrapableSocial(social: ArtistSocialProfile): boolean {
"youtube.com",
];

return scrapableDomains.some((domain) => profileUrl.includes(domain));
return scrapableDomains.some(domain => profileUrl.includes(domain));
}
4 changes: 1 addition & 3 deletions src/chats/getTaskRoomId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ type GetTaskRoomIdParams = {
* @param params - Parameters containing optional roomId and artistId
* @returns The room ID, or undefined if creation fails
*/
export async function getTaskRoomId(
params: GetTaskRoomIdParams
): Promise<string | undefined> {
export async function getTaskRoomId(params: GetTaskRoomIdParams): Promise<string | undefined> {
if (params.roomId) {
logger.log("Using existing roomId", { roomId: params.roomId });
return params.roomId;
Expand Down
2 changes: 1 addition & 1 deletion src/consts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const NEW_API_BASE_URL = "https://recoup-api.vercel.app";
export const RECOUP_API_KEY = process.env.RECOUP_API_KEY;
export const CODING_AGENT_ACCOUNT_ID = "04e3aba9-c130-4fb8-8b92-34e95d43e66b";
export const OPENCLAW_DEFAULT_MODEL = "vercel-ai-gateway/anthropic/claude-sonnet-4.6";
export const OPENCLAW_DEFAULT_MODEL = "vercel-ai-gateway/anthropic/claude-sonnet-4.6";
15 changes: 9 additions & 6 deletions src/content/analyzeClips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ export interface SongClip {
* @param lyrics - Timestamped lyrics from transcription
* @returns Array of clip recommendations
*/
export async function analyzeClips(
songTitle: string,
lyrics: SongLyrics,
): Promise<SongClip[]> {
export async function analyzeClips(songTitle: string, lyrics: SongLyrics): Promise<SongClip[]> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate parsed clip payload with Zod before returning.

JSON.parse(... ) as SongClip[] is a blind cast on external/LLM output. Add runtime validation and fallback on invalid shape to avoid propagating malformed clips.

Proposed fix
+import { z } from "zod";
 import { logger } from "@trigger.dev/sdk/v3";
 import type { SongLyrics } from "./transcribeSong";
@@
 export interface SongClip {
@@
 }
+
+const songClipSchema = z.object({
+  startSeconds: z.number(),
+  lyrics: z.string(),
+  reason: z.string(),
+  mood: z.string(),
+  hasLyrics: z.boolean(),
+});
+
+const songClipArraySchema = z.array(songClipSchema);
@@
   let clips: SongClip[];
   try {
-    clips = JSON.parse(jsonMatch[0]) as SongClip[];
+    const parsed = JSON.parse(jsonMatch[0]);
+    const result = songClipArraySchema.safeParse(parsed);
+    if (!result.success) {
+      logger.log("Invalid clip schema from API, using fallback", {
+        issues: result.error.issues.slice(0, 3),
+      });
+      return [
+        {
+          startSeconds: 0,
+          lyrics: lyrics.segments.slice(0, 10).map(s => s.text).join(" "),
+          reason: "fallback — invalid schema",
+          mood: "unknown",
+          hasLyrics: true,
+        },
+      ];
+    }
+    clips = result.data;
   } catch {

As per coding guidelines, "Use Zod for schema validation".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function analyzeClips(songTitle: string, lyrics: SongLyrics): Promise<SongClip[]> {
import { z } from "zod";
import { logger } from "@trigger.dev/sdk/v3";
import type { SongLyrics } from "./transcribeSong";
export interface SongClip {
startSeconds: number;
lyrics: string;
reason: string;
mood: string;
hasLyrics: boolean;
}
const songClipSchema = z.object({
startSeconds: z.number(),
lyrics: z.string(),
reason: z.string(),
mood: z.string(),
hasLyrics: z.boolean(),
});
const songClipArraySchema = z.array(songClipSchema);
export async function analyzeClips(songTitle: string, lyrics: SongLyrics): Promise<SongClip[]> {
let clips: SongClip[];
try {
const parsed = JSON.parse(jsonMatch[0]);
const result = songClipArraySchema.safeParse(parsed);
if (!result.success) {
logger.log("Invalid clip schema from API, using fallback", {
issues: result.error.issues.slice(0, 3),
});
return [
{
startSeconds: 0,
lyrics: lyrics.segments.slice(0, 10).map(s => s.text).join(" "),
reason: "fallback — invalid schema",
mood: "unknown",
hasLyrics: true,
},
];
}
clips = result.data;
} catch {
// existing error handling
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/content/analyzeClips.ts` at line 20, The analyzeClips function currently
does a blind cast of LLM output to SongClip[]; add Zod runtime validation for
the parsed payload (define a SongClipSchema using zod that matches the SongClip
shape) and run SongClipSchema.array().safeParse(parsed) before returning; if
validation fails, log or handle the error and return a safe fallback (e.g.,
empty array) rather than the invalid value, and update any callers expecting
SongClip[] accordingly.

const recoupApiKey = process.env.RECOUP_API_KEY;
if (!recoupApiKey) {
throw new Error("RECOUP_API_KEY is required for clip analysis");
Expand Down Expand Up @@ -107,7 +104,10 @@ IMPORTANT: startSeconds must align with actual word timestamps from the lyrics a
return [
{
startSeconds: 0,
lyrics: lyrics.segments.slice(0, 10).map(s => s.text).join(" "),
lyrics: lyrics.segments
.slice(0, 10)
.map(s => s.text)
.join(" "),
reason: "fallback — start of song",
mood: "unknown",
hasLyrics: true,
Expand All @@ -123,7 +123,10 @@ IMPORTANT: startSeconds must align with actual word timestamps from the lyrics a
return [
{
startSeconds: 0,
lyrics: lyrics.segments.slice(0, 10).map(s => s.text).join(" "),
lyrics: lyrics.segments
.slice(0, 10)
.map(s => s.text)
.join(" "),
reason: "fallback — JSON parse failed",
mood: "unknown",
hasLyrics: true,
Expand Down
13 changes: 12 additions & 1 deletion src/content/defaultPipelineConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@
* in their repo, but it's not required.
*/

type AspectRatio = "auto" | "21:9" | "16:9" | "3:2" | "4:3" | "5:4" | "1:1" | "4:5" | "3:4" | "2:3" | "9:16";
type AspectRatio =
| "auto"
| "21:9"
| "16:9"
| "3:2"
| "4:3"
| "5:4"
| "1:1"
| "4:5"
| "3:4"
| "2:3"
| "9:16";
type Resolution = "1K" | "2K" | "4K";

export interface PipelineConfig {
Expand Down
9 changes: 5 additions & 4 deletions src/content/fetchArtistContext.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
/**
* Fetches artist context (artist.md) from GitHub.
* Returns placeholder if not found.
*
* @param githubRepo
* @param artistSlug
* @param fetchFile
*/
export async function fetchArtistContext(
githubRepo: string,
artistSlug: string,
fetchFile: (repo: string, path: string) => Promise<Buffer | null>,
): Promise<string> {
const buffer = await fetchFile(
githubRepo,
`artists/${artistSlug}/context/artist.md`,
);
const buffer = await fetchFile(githubRepo, `artists/${artistSlug}/context/artist.md`);
if (!buffer) return "(no artist identity available)";
const content = buffer.toString("utf-8");
// Strip frontmatter if present
Expand Down
14 changes: 9 additions & 5 deletions src/content/fetchAudienceContext.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
/**
* Fetches audience context (audience.md) from GitHub.
* Returns placeholder if not found.
*
* @param githubRepo
* @param artistSlug
* @param fetchFile
*/
export async function fetchAudienceContext(
githubRepo: string,
artistSlug: string,
fetchFile: (repo: string, path: string) => Promise<Buffer | null>,
): Promise<string> {
const buffer = await fetchFile(
githubRepo,
`artists/${artistSlug}/context/audience.md`,
);
const buffer = await fetchFile(githubRepo, `artists/${artistSlug}/context/audience.md`);
if (!buffer) return "(no audience context available)";
return buffer.toString("utf-8").replace(/^---[\s\S]*?---\s*/, "").trim();
return buffer
.toString("utf-8")
.replace(/^---[\s\S]*?---\s*/, "")
.trim();
}
18 changes: 14 additions & 4 deletions src/content/fetchGithubFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { logger } from "@trigger.dev/sdk/v3";
* Fetches a raw file from a GitHub repo via the API.
* If the file isn't found in the main repo, checks submodule org repos.
*
* @param githubRepoUrl
* @param filePath
* @returns The file as a Buffer, or null if not found anywhere.
*/
export async function fetchGithubFile(
Expand Down Expand Up @@ -39,6 +41,10 @@ export async function fetchGithubFile(

/**
* Fetches a file from a specific GitHub repo.
*
* @param githubRepoUrl
* @param filePath
* @param token
*/
async function fetchFileFromRepo(
githubRepoUrl: string,
Expand Down Expand Up @@ -67,11 +73,11 @@ async function fetchFileFromRepo(

/**
* Reads .gitmodules from the main repo and extracts org submodule URLs.
*
* @param githubRepoUrl
* @param token
*/
async function getOrgRepoUrls(
githubRepoUrl: string,
token: string,
): Promise<string[]> {
async function getOrgRepoUrls(githubRepoUrl: string, token: string): Promise<string[]> {
const gitmodules = await fetchFileFromRepo(githubRepoUrl, ".gitmodules", token);
if (!gitmodules) return [];

Expand All @@ -87,6 +93,10 @@ async function getOrgRepoUrls(
return urls;
}

/**
*
* @param githubRepoUrl
*/
function parseRepoUrl(githubRepoUrl: string): { owner: string; repo: string } {
const match = githubRepoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
if (!match) {
Expand Down
36 changes: 23 additions & 13 deletions src/content/generateAudioVideo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ const execFileAsync = promisify(execFile);
*
* Matches the content-creation-app's generateAudioVideo.ts behavior.
*
* @param imageUrl.imageUrl
* @param imageUrl - URL of the AI-generated image
* @param songBuffer - Raw mp3 bytes of the song
* @param audioStartSeconds - Where to clip the song from
* @param audioDurationSeconds - How long the clip should be
* @param motionPrompt - Describes how the subject should move
* @param imageUrl.songBuffer
* @param imageUrl.audioStartSeconds
* @param imageUrl.audioDurationSeconds
* @param imageUrl.motionPrompt
* @returns URL of the generated video (with audio baked in)
*/
export async function generateAudioVideo({
Expand All @@ -37,19 +42,12 @@ export async function generateAudioVideo({
motionPrompt: string;
}): Promise<string> {
const config = DEFAULT_PIPELINE_CONFIG;
const durationSeconds = Math.min(
audioDurationSeconds,
config.audioVideoModelMaxSeconds,
);
const durationSeconds = Math.min(audioDurationSeconds, config.audioVideoModelMaxSeconds);
const fps = 25;
const numFrames = Math.round(durationSeconds * fps) + 1;

// Clip the audio to the right section and upload to fal
const audioUrl = await clipAndUploadAudio(
songBuffer,
audioStartSeconds,
durationSeconds,
);
const audioUrl = await clipAndUploadAudio(songBuffer, audioStartSeconds, durationSeconds);

logger.log("Generating audio-to-video (lipsync)", {
model: config.audioVideoModel,
Expand Down Expand Up @@ -89,6 +87,10 @@ export async function generateAudioVideo({

/**
* Clips the song mp3 to the specified range and uploads to fal storage.
*
* @param songBuffer
* @param startSeconds
* @param durationSeconds
*/
async function clipAndUploadAudio(
songBuffer: Buffer,
Expand All @@ -105,10 +107,14 @@ async function clipAndUploadAudio(

await execFileAsync("ffmpeg", [
"-y",
"-i", inputPath,
"-ss", String(startSeconds),
"-t", String(durationSeconds),
"-c", "copy",
"-i",
inputPath,
"-ss",
String(startSeconds),
"-t",
String(durationSeconds),
"-c",
"copy",
clippedPath,
]);

Expand All @@ -128,6 +134,10 @@ async function clipAndUploadAudio(
}
}

/**
*
* @param data
*/
function extractFalUrl(data: Record<string, unknown>): string | undefined {
for (const key of ["image", "video"]) {
if (data[key] && typeof data[key] === "object") {
Expand Down
23 changes: 17 additions & 6 deletions src/content/generateCaption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import type { SongLyrics } from "./transcribeSong";
import type { CaptionLength } from "../schemas/contentCreationSchema";

const CAPTION_LENGTH_INSTRUCTIONS: Record<CaptionLength, string> = {
short: "Write a SHORT caption (max 10 words). Punchy, minimal, like a text message. Think: one phrase that hits.",
medium: "Write a MEDIUM caption (15-30 words). A complete thought with feeling. 1-2 sentences max.",
short:
"Write a SHORT caption (max 10 words). Punchy, minimal, like a text message. Think: one phrase that hits.",
medium:
"Write a MEDIUM caption (15-30 words). A complete thought with feeling. 1-2 sentences max.",
long: "Write a LONG caption (40-80 words). A mini-story or stream of consciousness. Vulnerable, raw, the kind of caption people screenshot.",
};

Expand All @@ -14,6 +16,15 @@ const CAPTION_LENGTH_INSTRUCTIONS: Record<CaptionLength, string> = {
* Combines template style, artist context, song lyrics, and audience data.
*
* Matches the content-creation-app's generateCaption.ts behavior.
*
* @param root0
* @param root0.template
* @param root0.songTitle
* @param root0.fullLyrics
* @param root0.clipLyrics
* @param root0.artistContext
* @param root0.audienceContext
* @param root0.captionLength
*/
export async function generateCaption({
template,
Expand Down Expand Up @@ -41,9 +52,10 @@ export async function generateCaption({
? JSON.stringify(template.captionGuide, null, 2)
: "(no caption guide)";

const examples = template.captionExamples.length > 0
? template.captionExamples.map(c => `- "${c}"`).join("\n")
: "(no examples)";
const examples =
template.captionExamples.length > 0
? template.captionExamples.map(c => `- "${c}"`).join("\n")
: "(no examples)";

const lengthInstruction = CAPTION_LENGTH_INSTRUCTIONS[captionLength];

Expand Down Expand Up @@ -117,4 +129,3 @@ Generate ONE caption. ${lengthInstruction} Return ONLY the caption text, nothing
logger.log("Caption generated", { caption: captionText.slice(0, 80) });
return captionText;
}

9 changes: 8 additions & 1 deletion src/content/generateContentImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import { DEFAULT_PIPELINE_CONFIG } from "./defaultPipelineConfig";
* The prompt tells the model to replace the person in the reference scene
* with the person from the face-guide headshot.
*
* @param faceGuideUrl.faceGuideUrl
* @param faceGuideUrl - fal storage URL of the artist's face-guide (headshot)
* @param referenceImagePath - local path to a template reference image (or null)
* @param prompt - Scene/style prompt that instructs the face swap
* @param faceGuideUrl.referenceImagePath
* @param faceGuideUrl.prompt
Comment on lines +16 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Malformed JSDoc parameter annotations.

The JSDoc structure is incorrect. referenceImagePath and prompt are separate destructured parameters, not properties of faceGuideUrl. The @param faceGuideUrl.faceGuideUrl syntax is also invalid.

📝 Proposed fix for JSDoc
-* `@param` faceGuideUrl.faceGuideUrl
-* `@param` faceGuideUrl - fal storage URL of the artist's face-guide (headshot)
-* `@param` referenceImagePath - local path to a template reference image (or null)
-* `@param` prompt - Scene/style prompt that instructs the face swap
-* `@param` faceGuideUrl.referenceImagePath
-* `@param` faceGuideUrl.prompt
+* `@param` options - The generation options
+* `@param` options.faceGuideUrl - fal storage URL of the artist's face-guide (headshot)
+* `@param` options.referenceImagePath - local path to a template reference image (or null)
+* `@param` options.prompt - Scene/style prompt that instructs the face swap
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* @param faceGuideUrl.faceGuideUrl
* @param faceGuideUrl - fal storage URL of the artist's face-guide (headshot)
* @param referenceImagePath - local path to a template reference image (or null)
* @param prompt - Scene/style prompt that instructs the face swap
* @param faceGuideUrl.referenceImagePath
* @param faceGuideUrl.prompt
* `@param` options - The generation options
* `@param` options.faceGuideUrl - fal storage URL of the artist's face-guide (headshot)
* `@param` options.referenceImagePath - local path to a template reference image (or null)
* `@param` options.prompt - Scene/style prompt that instructs the face swap
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/content/generateContentImage.ts` around lines 16 - 21, The JSDoc for
generateContentImage has malformed `@param` entries (e.g., `@param`
faceGuideUrl.faceGuideUrl and treating referenceImagePath and prompt as
properties); update the comment to list each top-level parameter separately
(e.g., `@param` {string} faceGuideUrl - fal storage URL of the artist's face-guide
(headshot); `@param` {string|null} referenceImagePath - local path to a template
reference image; `@param` {string} prompt - scene/style prompt that instructs the
face swap), remove the dotted/property-style annotations, and ensure types and
short descriptions match the actual function signature (reference
generateContentImage, faceGuideUrl, referenceImagePath, prompt to locate the
code).

* @returns URL of the generated image
*/
export async function generateContentImage({
Expand Down Expand Up @@ -75,7 +78,11 @@ export async function generateContentImage({
return imageUrl;
}

/** Extracts a media URL from various fal.ai response shapes. */
/**
* Extracts a media URL from various fal.ai response shapes.
*
* @param data
*/
function extractFalUrl(data: Record<string, unknown>): string | undefined {
for (const key of ["image", "video"]) {
if (data[key] && typeof data[key] === "object") {
Expand Down
9 changes: 5 additions & 4 deletions src/content/generateContentVideo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { DEFAULT_PIPELINE_CONFIG } from "./defaultPipelineConfig";
/**
* Generates a video from an AI-generated image using fal.ai image-to-video.
*
* @param imageUrl.imageUrl
* @param imageUrl - URL of the source image (from generateContentImage)
* @param motionPrompt - Describes how the subject should move
* @param imageUrl.motionPrompt
* @returns URL of the generated video
*/
export async function generateContentVideo({
Expand All @@ -17,10 +19,7 @@ export async function generateContentVideo({
motionPrompt: string;
}): Promise<string> {
const config = DEFAULT_PIPELINE_CONFIG;
const durationSeconds = Math.min(
config.clipDuration,
config.videoModelMaxSeconds,
);
const durationSeconds = Math.min(config.clipDuration, config.videoModelMaxSeconds);

logger.log("Generating video", {
model: config.videoModel,
Expand Down Expand Up @@ -55,6 +54,8 @@ export async function generateContentVideo({

/**
* Extracts a media URL from various fal.ai response shapes.
*
* @param data
*/
function extractFalUrl(data: Record<string, unknown>): string | undefined {
for (const key of ["image", "video"]) {
Expand Down
Loading
Loading