807dfc539b
- Add thumbs-down feedback modal and preference API endpoint - Add AI UGC video platforms research doc - Add ReflectAd Remotion composition with public flow assets - Add gemini-ad-designer and poster-ad-designer pipeline skills - Add research_reflect_v1.1 pipeline script Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
126 lines
5.6 KiB
TypeScript
126 lines
5.6 KiB
TypeScript
import path from "path";
|
|
import { mkdirSync } from "fs";
|
|
import { runAgentStep } from "./claude";
|
|
import { getAllSettings } from "./settings";
|
|
|
|
const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
|
|
|
|
const PLATFORM_FORMATS: Record<string, { width: number; height: number; label: string; ratio: string }> = {
|
|
"instagram-feed": { width: 1080, height: 1080, label: "Instagram Feed", ratio: "1:1" },
|
|
"instagram-stories": { width: 1080, height: 1920, label: "Instagram Stories", ratio: "9:16" },
|
|
tiktok: { width: 1080, height: 1920, label: "TikTok", ratio: "9:16" },
|
|
"nextdoor-spotlight": { width: 1200, height: 1200, label: "Nextdoor Spotlight", ratio: "1:1" },
|
|
"nextdoor-display": { width: 1200, height: 628, label: "Nextdoor Display", ratio: "191:100" },
|
|
};
|
|
|
|
export function getAvailableFormats(currentDimensions: string | null): string[] {
|
|
return Object.keys(PLATFORM_FORMATS).filter((key) => {
|
|
const fmt = PLATFORM_FORMATS[key];
|
|
return `${fmt.width}x${fmt.height}` !== currentDimensions;
|
|
});
|
|
}
|
|
|
|
export function getPlatformFormat(key: string) {
|
|
return PLATFORM_FORMATS[key] || null;
|
|
}
|
|
|
|
async function loadPipelineEnv(): Promise<Record<string, string>> {
|
|
const settings = await getAllSettings();
|
|
const env: Record<string, string> = {};
|
|
if (settings.GEMINI_API_KEY) env.GEMINI_API_KEY = settings.GEMINI_API_KEY;
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Repurpose an image to target platform formats using Gemini via Claude CLI.
|
|
* Claude analyzes the original ad and instructs Gemini to regenerate it
|
|
* at the new dimensions with proper layout adaptation.
|
|
*/
|
|
export async function repurposeImage(
|
|
sourcePath: string,
|
|
sourceDimensions: string | null,
|
|
targetFormats: string[],
|
|
outputDir: string
|
|
): Promise<Array<{ filePath: string; fileName: string; dimensions: string; platform: string }>> {
|
|
mkdirSync(path.join(PIPELINE_ROOT, outputDir), { recursive: true });
|
|
|
|
const sourceBase = path.basename(sourcePath, path.extname(sourcePath));
|
|
const env = await loadPipelineEnv();
|
|
|
|
const formatInstructions = targetFormats.map((key) => {
|
|
const fmt = PLATFORM_FORMATS[key];
|
|
if (!fmt) return null;
|
|
const fileName = `${sourceBase}_${key}_${fmt.width}x${fmt.height}.png`;
|
|
return { key, fmt, fileName };
|
|
}).filter(Boolean) as Array<{ key: string; fmt: typeof PLATFORM_FORMATS[string]; fileName: string }>;
|
|
|
|
// Resolve absolute paths so the agent doesn't have to guess
|
|
const absPipelineRoot = path.resolve(PIPELINE_ROOT);
|
|
const absSourcePath = path.join(absPipelineRoot, sourcePath);
|
|
|
|
const prompt = `You are an ad creative reformatter. Your job is to take an existing ad image and recreate it at different dimensions, keeping ALL the same content — same text, same layout structure, same colors, same branding, same imagery — but properly recomposed for the new aspect ratio.
|
|
|
|
## Source Ad
|
|
- File: ${absSourcePath}
|
|
- Current dimensions: ${sourceDimensions || "unknown"}
|
|
|
|
First, use the Read tool to look at the source image so you can see exactly what it contains.
|
|
|
|
## What To Do
|
|
|
|
For EACH target format below, do this EXACT sequence:
|
|
1. Call mcp__nanobanana__set_aspect_ratio with the correct ratio string
|
|
2. Call mcp__nanobanana__gemini_generate_image with:
|
|
- reference_images: ["${absSourcePath}"]
|
|
- prompt: Describe in detail everything you see in the source ad — the exact headline text, body text, phone mockup, app screenshot, app icon, CTA button text and color, brand name, background color and style. Then say: "Recreate this ad with all these elements recomposed for the new aspect ratio. Keep all text word-for-word identical. Adapt the layout naturally for the new dimensions."
|
|
- output_path: the ABSOLUTE output path shown below
|
|
|
|
## Target Formats
|
|
|
|
${formatInstructions.map((f) => `### ${f.fmt.label} (${f.fmt.width}x${f.fmt.height})
|
|
- Aspect ratio for set_aspect_ratio: "${f.fmt.ratio}"
|
|
- output_path: "${path.join(absPipelineRoot, outputDir, f.fileName)}"`).join("\n\n")}
|
|
|
|
CRITICAL RULES:
|
|
- You MUST read the source image first to see what it contains
|
|
- Every piece of text from the original MUST appear in the output, word for word
|
|
- The visual style, colors, and branding must match exactly
|
|
- The layout should be ADAPTED for the new dimensions, not just cropped or padded
|
|
- Use the exact output_path values above — they are absolute paths
|
|
- The reference_images array must use the absolute source path above`;
|
|
|
|
await runAgentStep("repurpose-adapter", prompt, PIPELINE_ROOT, env);
|
|
|
|
// Return expected output info — the agent writes files to disk
|
|
return formatInstructions.map((f) => ({
|
|
filePath: path.join(outputDir, f.fileName),
|
|
fileName: f.fileName,
|
|
dimensions: `${f.fmt.width}x${f.fmt.height}`,
|
|
platform: f.key.split("-")[0],
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Re-tone a caption for a different platform using Claude CLI.
|
|
*/
|
|
export async function retoneCaption(
|
|
originalCaption: string,
|
|
originalPlatform: string,
|
|
targetPlatform: string
|
|
): Promise<string> {
|
|
const prompt = `You are a social media copywriter. Adapt this ${originalPlatform} caption for ${targetPlatform}.
|
|
|
|
Original caption:
|
|
${originalCaption}
|
|
|
|
Rules:
|
|
- ${targetPlatform === "tiktok" ? "Raw, authentic tone. Short. Trending hashtags. Hook in first line." : ""}
|
|
- ${targetPlatform === "instagram" ? "Polished, aspirational. Hook → Value → CTA → Hashtags. 150-300 chars." : ""}
|
|
- ${targetPlatform === "nextdoor" ? "Warm, neighborly. No hashtags. Reference local/community context." : ""}
|
|
- Keep the core message but match the platform's voice.
|
|
- Return ONLY the new caption text, nothing else.`;
|
|
|
|
const { output } = await runAgentStep("caption-retone", prompt, PIPELINE_ROOT, {});
|
|
return output.trim();
|
|
}
|