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
173 changes: 173 additions & 0 deletions src/schemas/__tests__/contentPrimitiveSchemas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import {
createImagePayloadSchema,
createVideoPayloadSchema,
createAudioPayloadSchema,
createRenderPayloadSchema,
createUpscalePayloadSchema,
textStyleSchema,
} from "../contentPrimitiveSchemas";

describe("createImagePayloadSchema", () => {
const base = {
accountId: "acc_123",
template: "artist-caption-bedroom",
artistSlug: "gatsby-grace",
githubRepo: "https://github.com/recoupable/test-repo",
};

it("parses a valid payload", () => {
expect(createImagePayloadSchema.safeParse(base).success).toBe(true);
});

it("accepts optional prompt and faceGuideUrl", () => {
const result = createImagePayloadSchema.safeParse({
...base,
prompt: "moody bedroom selfie",
faceGuideUrl: "https://example.com/face.png",
});
expect(result.success).toBe(true);
});

it("fails when template is missing", () => {
const { template: _, ...noTemplate } = base;
expect(createImagePayloadSchema.safeParse(noTemplate).success).toBe(false);
});

it("fails when githubRepo is not a URL", () => {
expect(createImagePayloadSchema.safeParse({ ...base, githubRepo: "not-a-url" }).success).toBe(false);
});
});

describe("createVideoPayloadSchema", () => {
const base = {
accountId: "acc_123",
imageUrl: "https://example.com/image.png",
};

it("parses a valid payload", () => {
expect(createVideoPayloadSchema.safeParse(base).success).toBe(true);
});

it("defaults lipsync to false", () => {
const result = createVideoPayloadSchema.safeParse(base);
expect(result.success).toBe(true);
if (result.success) expect(result.data.lipsync).toBe(false);
});

it("accepts lipsync with song URL", () => {
const result = createVideoPayloadSchema.safeParse({
...base,
lipsync: true,
songUrl: "https://example.com/song.mp3",
audioStartSeconds: 10,
audioDurationSeconds: 15,
});
expect(result.success).toBe(true);
});

it("fails when imageUrl is missing", () => {
expect(createVideoPayloadSchema.safeParse({ accountId: "acc_123" }).success).toBe(false);
});
});

describe("createAudioPayloadSchema", () => {
const base = {
accountId: "acc_123",
githubRepo: "https://github.com/recoupable/test-repo",
artistSlug: "gatsby-grace",
};

it("parses a valid payload", () => {
expect(createAudioPayloadSchema.safeParse(base).success).toBe(true);
});

it("accepts songs filter", () => {
const result = createAudioPayloadSchema.safeParse({ ...base, songs: ["hiccups", "https://example.com/track.mp3"] });
expect(result.success).toBe(true);
if (result.success) expect(result.data.songs).toEqual(["hiccups", "https://example.com/track.mp3"]);
});

it("fails when artistSlug is missing", () => {
const { artistSlug: _, ...noSlug } = base;
expect(createAudioPayloadSchema.safeParse(noSlug).success).toBe(false);
});
});

describe("textStyleSchema", () => {
it("parses content only", () => {
expect(textStyleSchema.safeParse({ content: "hello world" }).success).toBe(true);
});

it("parses with all style fields", () => {
const result = textStyleSchema.safeParse({
content: "test caption",
font: "TikTokSans.ttf",
color: "white",
borderColor: "black",
maxFontSize: 42,
});
expect(result.success).toBe(true);
});

it("fails when content is empty", () => {
expect(textStyleSchema.safeParse({ content: "" }).success).toBe(false);
});
});

describe("createRenderPayloadSchema", () => {
const base = {
accountId: "acc_123",
videoUrl: "https://example.com/video.mp4",
songUrl: "https://example.com/song.mp3",
audioStartSeconds: 10,
audioDurationSeconds: 15,
text: { content: "he was just taking notes" },
};

it("parses a valid payload", () => {
expect(createRenderPayloadSchema.safeParse(base).success).toBe(true);
});

it("defaults hasAudio to false", () => {
const result = createRenderPayloadSchema.safeParse(base);
expect(result.success).toBe(true);
if (result.success) expect(result.data.hasAudio).toBe(false);
});

it("fails when text.content is missing", () => {
expect(createRenderPayloadSchema.safeParse({ ...base, text: {} }).success).toBe(false);
});

it("fails when videoUrl is not a URL", () => {
expect(createRenderPayloadSchema.safeParse({ ...base, videoUrl: "bad" }).success).toBe(false);
});
});

describe("createUpscalePayloadSchema", () => {
it("parses image upscale", () => {
const result = createUpscalePayloadSchema.safeParse({
accountId: "acc_123",
url: "https://example.com/image.png",
type: "image",
});
expect(result.success).toBe(true);
});

it("parses video upscale", () => {
const result = createUpscalePayloadSchema.safeParse({
accountId: "acc_123",
url: "https://example.com/video.mp4",
type: "video",
});
expect(result.success).toBe(true);
});

it("fails on invalid type", () => {
expect(createUpscalePayloadSchema.safeParse({
accountId: "acc_123",
url: "https://example.com/file",
type: "audio",
}).success).toBe(false);
});
});
60 changes: 60 additions & 0 deletions src/schemas/contentPrimitiveSchemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { z } from "zod";

export const createImagePayloadSchema = z.object({
accountId: z.string().min(1),
template: z.string().min(1),
artistSlug: z.string().min(1),
githubRepo: z.string().url(),
prompt: z.string().optional(),
faceGuideUrl: z.string().url().optional(),
images: z.array(z.string().url()).optional(),
});
export type CreateImagePayload = z.infer<typeof createImagePayloadSchema>;

export const createVideoPayloadSchema = z.object({
accountId: z.string().min(1),
imageUrl: z.string().url(),
template: z.string().optional(),
lipsync: z.boolean().default(false),
songUrl: z.string().url().optional(),
audioStartSeconds: z.number().optional(),
audioDurationSeconds: z.number().optional(),
motionPrompt: z.string().optional(),
});
export type CreateVideoPayload = z.infer<typeof createVideoPayloadSchema>;

export const createAudioPayloadSchema = z.object({
accountId: z.string().min(1),
githubRepo: z.string().url(),
artistSlug: z.string().min(1),
lipsync: z.boolean().default(false),
songs: z.array(z.string()).optional(),
});
export type CreateAudioPayload = z.infer<typeof createAudioPayloadSchema>;

export const textStyleSchema = z.object({
content: z.string().min(1),
font: z.string().optional(),
color: z.string().optional(),
borderColor: z.string().optional(),
maxFontSize: z.number().optional(),
});
export type TextStyle = z.infer<typeof textStyleSchema>;

export const createRenderPayloadSchema = z.object({
accountId: z.string().min(1),
videoUrl: z.string().url(),
songUrl: z.string().url(),
audioStartSeconds: z.number(),
audioDurationSeconds: z.number(),
text: textStyleSchema,
hasAudio: z.boolean().default(false),
});
export type CreateRenderPayload = z.infer<typeof createRenderPayloadSchema>;

export const createUpscalePayloadSchema = z.object({
accountId: z.string().min(1),
url: z.string().url(),
type: z.enum(["image", "video"]),
});
export type CreateUpscalePayload = z.infer<typeof createUpscalePayloadSchema>;
45 changes: 45 additions & 0 deletions src/tasks/createAudioTask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { fal } from "@fal-ai/client";
import { schemaTask, tags } from "@trigger.dev/sdk/v3";
import { createAudioPayloadSchema } from "../schemas/contentPrimitiveSchemas";
import { logStep } from "../sandboxes/logStep";
import { resolveAudioClip } from "../content/resolveAudioClip";

export const createAudioTask = schemaTask({
id: "create-audio",
schema: createAudioPayloadSchema,
maxDuration: 60 * 3,
machine: "micro",
retry: { maxAttempts: 1 },
run: async (payload) => {
await tags.add(`account:${payload.accountId}`);

const falKey = process.env.FAL_KEY;
if (!falKey) throw new Error("FAL_KEY environment variable is required");
fal.config({ credentials: falKey });

logStep("Selecting audio clip");
const clip = await resolveAudioClip(payload);

// Upload the song buffer to fal storage so callers can reference it
const songFile = new File([clip.songBuffer], clip.songFilename, { type: "audio/mpeg" });
const songUrl = await fal.storage.upload(songFile);

logStep("Audio clip selected", true, {
songTitle: clip.songTitle,
startSeconds: clip.startSeconds,
clipLyrics: clip.clipLyrics.slice(0, 80),
});

return {
songTitle: clip.songTitle,
songFilename: clip.songFilename,
songUrl,
startSeconds: clip.startSeconds,
durationSeconds: clip.durationSeconds,
fullLyrics: clip.lyrics.fullLyrics,
clipLyrics: clip.clipLyrics,
clipReason: clip.clipReason,
clipMood: clip.clipMood,
};
},
});
54 changes: 54 additions & 0 deletions src/tasks/createImageTask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { fal } from "@fal-ai/client";
import { schemaTask, tags } from "@trigger.dev/sdk/v3";
import { createImagePayloadSchema } from "../schemas/contentPrimitiveSchemas";
import { logStep } from "../sandboxes/logStep";
import { resolveFaceGuide } from "../content/resolveFaceGuide";
import { generateContentImage } from "../content/generateContentImage";
import {
loadTemplate,
pickRandomReferenceImage,
buildImagePrompt,
} from "../content/loadTemplate";
import { resolveImageInstruction } from "../content/resolveImageInstruction";

export const createImageTask = schemaTask({
id: "create-image",
schema: createImagePayloadSchema,
maxDuration: 60 * 2,
machine: "micro",
retry: { maxAttempts: 1 },
run: async (payload) => {
await tags.add(`account:${payload.accountId}`);

const falKey = process.env.FAL_KEY;
if (!falKey) throw new Error("FAL_KEY environment variable is required");
fal.config({ credentials: falKey });

logStep("Loading template for image generation");
const template = await loadTemplate(payload.template);

const faceGuideUrl = await resolveFaceGuide({
usesFaceGuide: template.usesFaceGuide,
images: payload.images,
githubRepo: payload.githubRepo,
artistSlug: payload.artistSlug,
});

const referenceImagePath = pickRandomReferenceImage(template);
const instruction = resolveImageInstruction(template);
const basePrompt = payload.prompt
? `${instruction} ${payload.prompt}`
: `${instruction} ${template.imagePrompt}`;
const fullPrompt = buildImagePrompt(basePrompt, template.styleGuide);

logStep("Generating image");
const imageUrl = await generateContentImage({
faceGuideUrl: payload.faceGuideUrl ?? faceGuideUrl ?? undefined,
referenceImagePath,
prompt: fullPrompt,
});

logStep("Image generated", true, { imageUrl: imageUrl.slice(0, 60) });
return { imageUrl };
},
});
34 changes: 34 additions & 0 deletions src/tasks/createRenderTask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { schemaTask, tags } from "@trigger.dev/sdk/v3";
import { createRenderPayloadSchema } from "../schemas/contentPrimitiveSchemas";
import { logStep } from "../sandboxes/logStep";
import { renderFinalVideo } from "../content/renderFinalVideo";

export const createRenderTask = schemaTask({
id: "create-render",
schema: createRenderPayloadSchema,
maxDuration: 60 * 2,
machine: "medium-1x",
retry: { maxAttempts: 0 },
run: async (payload) => {
await tags.add(`account:${payload.accountId}`);

// Download the song from URL to get a Buffer
logStep("Downloading song for render");
const songResponse = await fetch(payload.songUrl);
if (!songResponse.ok) throw new Error(`Failed to download song: ${songResponse.status}`);
const songBuffer = Buffer.from(await songResponse.arrayBuffer());

logStep("Rendering final video");
const result = await renderFinalVideo({
videoUrl: payload.videoUrl,
songBuffer,
audioStartSeconds: payload.audioStartSeconds,
audioDurationSeconds: payload.audioDurationSeconds,
captionText: payload.text.content,
hasAudio: payload.hasAudio,
});

logStep("Render complete", true, { sizeBytes: result.sizeBytes });
return { videoUrl: result.videoUrl, sizeBytes: result.sizeBytes };
Comment on lines +31 to +32
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the return type of renderFinalVideo
ast-grep --pattern $'export interface RenderFinalVideoOutput {
  $$$
}'

Repository: recoupable/tasks

Length of output: 539


🏁 Script executed:

#!/bin/bash
# Read the createRenderTask.ts file around line 32
sed -n '25,40p' src/tasks/createRenderTask.ts

Repository: recoupable/tasks

Length of output: 404


Incorrect property access: renderFinalVideo returns dataUrl, not videoUrl.

The RenderFinalVideoOutput interface defines { dataUrl, mimeType, sizeBytes }. Accessing result.videoUrl on line 32 will return undefined.

🐛 Proposed fix
     logStep("Render complete", true, { sizeBytes: result.sizeBytes });
-    return { videoUrl: result.videoUrl, sizeBytes: result.sizeBytes };
+    return { videoUrl: result.dataUrl, sizeBytes: result.sizeBytes };
📝 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
logStep("Render complete", true, { sizeBytes: result.sizeBytes });
return { videoUrl: result.videoUrl, sizeBytes: result.sizeBytes };
logStep("Render complete", true, { sizeBytes: result.sizeBytes });
return { videoUrl: result.dataUrl, sizeBytes: result.sizeBytes };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tasks/createRenderTask.ts` around lines 31 - 32, The code incorrectly
reads result.videoUrl but renderFinalVideo (RenderFinalVideoOutput) returns
dataUrl; update the return to use result.dataUrl (or rename to dataUrl
consistently) and keep sizeBytes: return { videoUrl: result.dataUrl, sizeBytes:
result.sizeBytes } or, if you prefer explicit types, change the returned shape
to { dataUrl: result.dataUrl, mimeType: result.mimeType, sizeBytes:
result.sizeBytes } and adjust callers accordingly (look for renderFinalVideo,
RenderFinalVideoOutput, and logStep usage).

},
});
Loading
Loading