diff --git a/src/content/__tests__/falSubscribe.test.ts b/src/content/__tests__/falSubscribe.test.ts new file mode 100644 index 0000000..ac5d5d5 --- /dev/null +++ b/src/content/__tests__/falSubscribe.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { falSubscribe } from "../falSubscribe"; + +const mockSubscribe = vi.fn(); +vi.mock("@fal-ai/client", () => ({ + fal: { subscribe: (...args: unknown[]) => mockSubscribe(...args) }, +})); + +vi.mock("@trigger.dev/sdk/v3", () => ({ + logger: { error: vi.fn() }, +})); + +describe("falSubscribe", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns fal result on success", async () => { + const expected = { data: { images: [{ url: "fal://img.png" }] } }; + mockSubscribe.mockResolvedValue(expected); + + const result = await falSubscribe("fal-ai/some-model", { prompt: "test" }); + expect(result).toBe(expected); + expect(mockSubscribe).toHaveBeenCalledWith("fal-ai/some-model", { + input: { prompt: "test" }, + logs: true, + }); + }); + + it("logs error details and rethrows on failure", async () => { + const falError = Object.assign(new Error("Unprocessable Entity"), { + status: 422, + body: { detail: "bad input" }, + }); + mockSubscribe.mockRejectedValue(falError); + + const { logger } = await import("@trigger.dev/sdk/v3"); + + await expect( + falSubscribe("fal-ai/some-model", { prompt: "test" }), + ).rejects.toThrow("Unprocessable Entity"); + + expect(logger.error).toHaveBeenCalledWith( + "fal.ai request failed", + expect.objectContaining({ + model: "fal-ai/some-model", + status: 422, + message: "Unprocessable Entity", + }), + ); + }); + + it("handles circular error objects safely", async () => { + const circular: Record = { message: "fail" }; + circular.self = circular; + mockSubscribe.mockRejectedValue(circular); + + await expect( + falSubscribe("fal-ai/some-model", { prompt: "test" }), + ).rejects.toBe(circular); + }); +}); diff --git a/src/content/__tests__/resolveFaceGuide.test.ts b/src/content/__tests__/resolveFaceGuide.test.ts index e603a60..f2707c3 100644 --- a/src/content/__tests__/resolveFaceGuide.test.ts +++ b/src/content/__tests__/resolveFaceGuide.test.ts @@ -26,10 +26,10 @@ describe("resolveFaceGuide", () => { vi.clearAllMocks(); }); - it("returns null when template does not use face guide", async () => { + it("returns null when usesFaceGuide is false and no images provided", async () => { const result = await resolveFaceGuide({ usesFaceGuide: false, - images: ["https://example.com/face.png"], + images: undefined, githubRepo: "https://github.com/test/repo", artistSlug: "artist", }); @@ -38,6 +38,20 @@ describe("resolveFaceGuide", () => { expect(fetchImageFromUrl).not.toHaveBeenCalled(); }); + it("passes attached images through even when usesFaceGuide is false", async () => { + vi.mocked(fetchImageFromUrl).mockResolvedValue("https://fal.ai/uploaded.png"); + + const result = await resolveFaceGuide({ + usesFaceGuide: false, + images: ["https://example.com/album-cover.png"], + githubRepo: "https://github.com/test/repo", + artistSlug: "artist", + }); + + expect(fetchImageFromUrl).toHaveBeenCalledWith("https://example.com/album-cover.png"); + expect(result).toBe("https://fal.ai/uploaded.png"); + }); + it("uses fetchImageFromUrl when images array has entries", async () => { vi.mocked(fetchImageFromUrl).mockResolvedValue("https://fal.ai/uploaded.png"); diff --git a/src/content/__tests__/resolveImageInstruction.test.ts b/src/content/__tests__/resolveImageInstruction.test.ts new file mode 100644 index 0000000..7766ba5 --- /dev/null +++ b/src/content/__tests__/resolveImageInstruction.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { resolveImageInstruction } from "../resolveImageInstruction"; +import { FACE_SWAP_INSTRUCTION, NO_FACE_INSTRUCTION } from "../contentPrompts"; +import type { TemplateData } from "../loadTemplate"; + +function makeTemplate(overrides: Partial = {}): TemplateData { + return { + name: "test", + imagePrompt: "", + usesFaceGuide: false, + styleGuide: null, + captionGuide: null, + captionExamples: [], + videoMoods: [], + videoMovements: [], + referenceImagePaths: [], + ...overrides, + }; +} + +describe("resolveImageInstruction", () => { + it("uses customInstruction from style guide when present", () => { + const result = resolveImageInstruction( + makeTemplate({ styleGuide: { customInstruction: "Place the album art onto a vinyl sleeve." } }), + ); + expect(result).toBe("Place the album art onto a vinyl sleeve."); + }); + + it("uses customInstruction even when usesFaceGuide is true", () => { + const result = resolveImageInstruction( + makeTemplate({ usesFaceGuide: true, styleGuide: { customInstruction: "Custom instruction here." } }), + ); + expect(result).toBe("Custom instruction here."); + }); + + it("falls back to FACE_SWAP_INSTRUCTION when usesFaceGuide is true and no customInstruction", () => { + const result = resolveImageInstruction( + makeTemplate({ usesFaceGuide: true, styleGuide: {} }), + ); + expect(result).toBe(FACE_SWAP_INSTRUCTION); + }); + + it("falls back to NO_FACE_INSTRUCTION when usesFaceGuide is false and no customInstruction", () => { + const result = resolveImageInstruction( + makeTemplate({ styleGuide: {} }), + ); + expect(result).toBe(NO_FACE_INSTRUCTION); + }); + + it("falls back correctly when styleGuide is null", () => { + const result = resolveImageInstruction( + makeTemplate({ usesFaceGuide: true }), + ); + expect(result).toBe(FACE_SWAP_INSTRUCTION); + }); + + it("treats whitespace-only customInstruction as empty", () => { + const result = resolveImageInstruction( + makeTemplate({ styleGuide: { customInstruction: " " } }), + ); + expect(result).toBe(NO_FACE_INSTRUCTION); + }); +}); diff --git a/src/content/falSubscribe.ts b/src/content/falSubscribe.ts new file mode 100644 index 0000000..c9a5b82 --- /dev/null +++ b/src/content/falSubscribe.ts @@ -0,0 +1,30 @@ +import { fal } from "@fal-ai/client"; +import { logger } from "@trigger.dev/sdk/v3"; + +/** + * Wraps fal.subscribe with structured error logging. + * Logs model, status, and a safely-serialized body on failure, then rethrows. + */ +export async function falSubscribe( + model: string, + input: Record, +): Promise> { + try { + return await fal.subscribe(model, { input, logs: true }); + } catch (error: unknown) { + const err = error as Record; + let body: string; + try { + body = JSON.stringify(err.body ?? err).slice(0, 1000); + } catch { + body = String(err).slice(0, 1000); + } + logger.error("fal.ai request failed", { + model, + status: err.status, + message: err.message, + body, + }); + throw error; + } +} diff --git a/src/content/generateContentImage.ts b/src/content/generateContentImage.ts index ab7c05a..e0bc3de 100644 --- a/src/content/generateContentImage.ts +++ b/src/content/generateContentImage.ts @@ -1,21 +1,22 @@ import fs from "node:fs/promises"; import { fal } from "@fal-ai/client"; import { logger } from "@trigger.dev/sdk/v3"; +import { logStep } from "../sandboxes/logStep"; import { DEFAULT_PIPELINE_CONFIG } from "./defaultPipelineConfig"; +import { falSubscribe } from "./falSubscribe"; /** - * Generates an AI image of the artist using fal.ai. + * Generates an AI image using fal.ai. * - * Takes two images: - * 1. Face-guide (headshot on white/plain background) — the artist's identity - * 2. Reference image (composition with a different person) — the scene/setting + * Takes up to two images: + * 1. Guide image (face-guide headshot or album cover) — the primary subject + * 2. Reference image (scene composition from template) — the setting * - * The prompt tells the model to replace the person in the reference scene - * with the person from the face-guide headshot. + * The prompt tells the model how to combine these images. * - * @param faceGuideUrl - fal storage URL of the artist's face-guide (headshot) + * @param faceGuideUrl - fal storage URL of the guide image (face or album cover) * @param referenceImagePath - local path to a template reference image (or null) - * @param prompt - Scene/style prompt that instructs the face swap + * @param prompt - Scene/style prompt with instructions for how to use the images * @returns URL of the generated image */ export async function generateContentImage({ @@ -23,14 +24,14 @@ export async function generateContentImage({ referenceImagePath, prompt, }: { - /** Face-guide URL — omit for templates that don't use the artist's face. */ + /** Guide image URL — omit for templates that don't use an input image. */ faceGuideUrl?: string; referenceImagePath: string | null; prompt: string; }): Promise { const config = DEFAULT_PIPELINE_CONFIG; - // Build image_urls: face-guide (if provided) + reference image (if provided) + // Build image_urls: guide image (if provided) + reference image (if provided) const imageUrls: string[] = []; if (faceGuideUrl) imageUrls.push(faceGuideUrl); @@ -44,22 +45,21 @@ export async function generateContentImage({ imageUrls.push(refUrl); } - logger.log("Generating image", { + logStep("Generating image", false, { model: config.imageModel, - prompt: prompt.slice(0, 100), + promptLength: prompt.length, imageCount: imageUrls.length, + hasFaceGuide: Boolean(faceGuideUrl), + hasReferenceImage: Boolean(referenceImagePath), }); - const result = await fal.subscribe(config.imageModel, { - input: { - prompt, - image_urls: imageUrls, - aspect_ratio: config.aspectRatio, - resolution: config.resolution, - output_format: "png", - num_images: 1, - }, - logs: true, + const result = await falSubscribe(config.imageModel, { + prompt, + image_urls: imageUrls, + aspect_ratio: config.aspectRatio, + resolution: config.resolution, + output_format: "png", + num_images: 1, }); const data = result.data as Record; diff --git a/src/content/resolveFaceGuide.ts b/src/content/resolveFaceGuide.ts index ac6459f..3d5a0bd 100644 --- a/src/content/resolveFaceGuide.ts +++ b/src/content/resolveFaceGuide.ts @@ -21,13 +21,14 @@ export async function resolveFaceGuide({ githubRepo: string; artistSlug: string; }): Promise { - if (!usesFaceGuide) return null; - + // Always pass attached images through (e.g. album cover art — no face-swap needed). const imageUrl = images?.[0]; if (imageUrl) { return fetchImageFromUrl(imageUrl); } + if (!usesFaceGuide) return null; + logStep("Fetching face-guide from GitHub"); const buffer = await fetchGithubFile( githubRepo, diff --git a/src/content/resolveImageInstruction.ts b/src/content/resolveImageInstruction.ts new file mode 100644 index 0000000..c5e62d2 --- /dev/null +++ b/src/content/resolveImageInstruction.ts @@ -0,0 +1,13 @@ +import { FACE_SWAP_INSTRUCTION, NO_FACE_INSTRUCTION } from "./contentPrompts"; +import type { TemplateData } from "./loadTemplate"; + +/** + * Picks the image-generation instruction for the prompt. + * Uses the template's customInstruction when available, + * otherwise falls back to the default face-swap or no-face instruction. + */ +export function resolveImageInstruction(template: TemplateData): string { + const custom = template.styleGuide?.customInstruction; + if (typeof custom === "string" && custom.trim().length > 0) return custom.trim(); + return template.usesFaceGuide ? FACE_SWAP_INSTRUCTION : NO_FACE_INSTRUCTION; +} 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/references/images/ref-01.png b/src/content/templates/album-record-store/references/images/ref-01.png new file mode 100644 index 0000000..c0ef0cc Binary files /dev/null and b/src/content/templates/album-record-store/references/images/ref-01.png differ diff --git a/src/content/templates/album-record-store/references/images/ref-02.png b/src/content/templates/album-record-store/references/images/ref-02.png new file mode 100644 index 0000000..77c33bf Binary files /dev/null and b/src/content/templates/album-record-store/references/images/ref-02.png differ diff --git a/src/content/templates/album-record-store/references/images/ref-03.png b/src/content/templates/album-record-store/references/images/ref-03.png new file mode 100644 index 0000000..9432c22 Binary files /dev/null and b/src/content/templates/album-record-store/references/images/ref-03.png differ diff --git a/src/content/templates/album-record-store/references/images/ref-04.png b/src/content/templates/album-record-store/references/images/ref-04.png new file mode 100644 index 0000000..602aad3 Binary files /dev/null and b/src/content/templates/album-record-store/references/images/ref-04.png differ diff --git a/src/content/templates/album-record-store/references/images/ref-05.png b/src/content/templates/album-record-store/references/images/ref-05.png new file mode 100644 index 0000000..89adc82 Binary files /dev/null and b/src/content/templates/album-record-store/references/images/ref-05.png differ diff --git a/src/content/templates/album-record-store/references/images/ref-06.png b/src/content/templates/album-record-store/references/images/ref-06.png new file mode 100644 index 0000000..dee3522 Binary files /dev/null and b/src/content/templates/album-record-store/references/images/ref-06.png differ diff --git a/src/content/templates/album-record-store/references/images/ref-07.png b/src/content/templates/album-record-store/references/images/ref-07.png new file mode 100644 index 0000000..7a9699d Binary files /dev/null and b/src/content/templates/album-record-store/references/images/ref-07.png differ diff --git a/src/content/templates/album-record-store/references/images/ref-08.png b/src/content/templates/album-record-store/references/images/ref-08.png new file mode 100644 index 0000000..87419d9 Binary files /dev/null and b/src/content/templates/album-record-store/references/images/ref-08.png differ diff --git a/src/content/templates/album-record-store/references/images/ref-09.png b/src/content/templates/album-record-store/references/images/ref-09.png new file mode 100644 index 0000000..52fde00 Binary files /dev/null and b/src/content/templates/album-record-store/references/images/ref-09.png differ 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..7b86489 --- /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": false, + "customInstruction": "Place the album cover art from the first image into the scene in two ways: (1) The vinyl record spinning on the turntable MUST display the same album artwork — the label and sleeve art on the spinning record must match the provided album cover exactly. (2) The full album cover should also be clearly visible nearby — propped up on a shelf, leaning against a crate, or displayed on the counter next to the turntable. Do NOT alter the album art — reproduce it exactly in both locations, including its original text/design. Do not add any extra scene overlays, subtitles, captions, or watermarks outside the album artwork.", + "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 5309f63..62a323f 100644 --- a/src/tasks/createContentTask.ts +++ b/src/tasks/createContentTask.ts @@ -20,7 +20,7 @@ import { buildImagePrompt, buildMotionPrompt, } from "../content/loadTemplate"; -import { FACE_SWAP_INSTRUCTION, NO_FACE_INSTRUCTION } from "../content/contentPrompts"; +import { resolveImageInstruction } from "../content/resolveImageInstruction"; /** * Content-creation task — full pipeline that generates a social-ready video. @@ -92,8 +92,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; + // Build prompt: custom/face-swap/no-face instruction + template scene + style guide + const instruction = resolveImageInstruction(template); const basePrompt = `${instruction} ${template.imagePrompt}`; const fullPrompt = buildImagePrompt(basePrompt, template.styleGuide); let imageUrl = await generateContentImage({