Skip to content
Merged
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
62 changes: 62 additions & 0 deletions src/content/__tests__/falSubscribe.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { message: "fail" };
circular.self = circular;
mockSubscribe.mockRejectedValue(circular);

await expect(
falSubscribe("fal-ai/some-model", { prompt: "test" }),
).rejects.toBe(circular);
});
});
18 changes: 16 additions & 2 deletions src/content/__tests__/resolveFaceGuide.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand All @@ -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");

Expand Down
63 changes: 63 additions & 0 deletions src/content/__tests__/resolveImageInstruction.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
30 changes: 30 additions & 0 deletions src/content/falSubscribe.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
): Promise<ReturnType<typeof fal.subscribe>> {
try {
return await fal.subscribe(model, { input, logs: true });
} catch (error: unknown) {
const err = error as Record<string, unknown>;
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;
}
}
44 changes: 22 additions & 22 deletions src/content/generateContentImage.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,37 @@
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({
faceGuideUrl,
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<string> {
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);

Expand All @@ -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<string, unknown>;
Expand Down
5 changes: 3 additions & 2 deletions src/content/resolveFaceGuide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ export async function resolveFaceGuide({
githubRepo: string;
artistSlug: string;
}): Promise<string | null> {
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,
Expand Down
13 changes: 13 additions & 0 deletions src/content/resolveImageInstruction.ts
Original file line number Diff line number Diff line change
@@ -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;
}
30 changes: 30 additions & 0 deletions src/content/templates/album-record-store/caption-guide.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
Original file line number Diff line number Diff line change
@@ -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"
]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions src/content/templates/album-record-store/style-guide.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
10 changes: 10 additions & 0 deletions src/content/templates/album-record-store/video-moods.json
Original file line number Diff line number Diff line change
@@ -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"
]
Loading
Loading