-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add standalone primitive tasks for modular content creation #122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
recoupableorg
wants to merge
2
commits into
main
Choose a base branch
from
feature/content-primitives
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }; | ||
| }, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| }, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| }, | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: recoupable/tasks
Length of output: 539
🏁 Script executed:
Repository: recoupable/tasks
Length of output: 404
Incorrect property access:
renderFinalVideoreturnsdataUrl, notvideoUrl.The
RenderFinalVideoOutputinterface defines{ dataUrl, mimeType, sizeBytes }. Accessingresult.videoUrlon line 32 will returnundefined.🐛 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
🤖 Prompt for AI Agents