feat: add asset preferences, video research, and Remotion ad assets

- 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>
This commit is contained in:
Trey t
2026-05-03 20:28:07 -05:00
parent b318798ca7
commit 807dfc539b
40 changed files with 3089 additions and 232 deletions
+1
View File
@@ -28,4 +28,5 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
signIn: "/login",
},
secret: process.env.NEXTAUTH_SECRET,
trustHost: true,
});
+132 -16
View File
@@ -13,7 +13,8 @@ const AGENT_STEPS = [
"trend-scout",
"marketing-research-agent",
"script-writer",
"ad-creative-designer",
"gemini-ad-designer",
"poster-ad-designer",
"video-ad-producer",
"copywriter-agent",
"distribution-agent",
@@ -31,6 +32,7 @@ export interface AppConfig {
brandIdentity: string | null;
productInfo: string | null;
platformGuidelines: string | null;
stylePreferences: string | null;
}
interface CampaignConfig {
@@ -122,6 +124,82 @@ Read CLAUDE.md first. Then execute each agent skill in order:
CRITICAL: Read each skill's SKILL.md before executing. Follow the skill instructions exactly.`;
}
/**
* Build a style preferences prompt section from app config.
* Groups liked references by style tag (with-people / without-people) set at
* generation time so the correct reference images are used for matching ads.
* Disliked reasons are text-only and universal.
*/
function buildStylePreferencesSection(appConfig?: AppConfig): string {
if (!appConfig?.stylePreferences) return "";
interface PrefEntry { filePath: string; fileName: string; style?: string | null }
interface DislikeEntry { reason: string; fileName: string; style?: string | null }
let prefs: { liked?: PrefEntry[]; disliked?: DislikeEntry[] };
try {
prefs = JSON.parse(appConfig.stylePreferences);
} catch {
return "";
}
const liked = prefs.liked || [];
const disliked = prefs.disliked || [];
if (liked.length === 0 && disliked.length === 0) return "";
const withPeople = liked.filter((e) => e.style === "with-people");
const withoutPeople = liked.filter((e) => e.style === "without-people");
const untagged = liked.filter((e) => !e.style);
const lines: string[] = ["## Style Preferences (learned from user feedback)", ""];
if (withPeople.length > 0) {
lines.push("### DO — liked WITH-PEOPLE ads (use as reference_images ONLY for ads with people):");
for (const entry of withPeople) {
lines.push(`- "${entry.fileName}"`);
}
const refs = withPeople.slice(-2);
lines.push("Reference image paths:");
for (const entry of refs) {
lines.push(`- ${entry.filePath}`);
}
lines.push("");
}
if (withoutPeople.length > 0) {
lines.push("### DO — liked WITHOUT-PEOPLE ads (use as reference_images ONLY for product-focused ads):");
for (const entry of withoutPeople) {
lines.push(`- "${entry.fileName}"`);
}
const refs = withoutPeople.slice(-2);
lines.push("Reference image paths:");
for (const entry of refs) {
lines.push(`- ${entry.filePath}`);
}
lines.push("");
}
if (untagged.length > 0) {
lines.push("### DO — liked ads (general inspiration, use for any ad type):");
for (const entry of untagged) {
lines.push(`- "${entry.fileName}"`);
}
const refs = untagged.slice(-2);
lines.push("Reference image paths:");
for (const entry of refs) {
lines.push(`- ${entry.filePath}`);
}
lines.push("");
}
if (disliked.length > 0) {
lines.push("### DON'T — styles the user dislikes (applies to ALL ad types):");
for (const entry of disliked) {
lines.push(`- Avoid: "${entry.reason}" (from "${entry.fileName}")`);
}
lines.push("");
}
return lines.join("\n");
}
/**
* Build a focused prompt for a single agent step.
*/
@@ -198,9 +276,9 @@ Save outputs to: ${outputDir}/scripts/
${campaignBrief}`,
"ad-creative-designer": `You are the Ad Creative Designer agent.
"gemini-ad-designer": `You are the Gemini Ad Designer agent.
Read and follow the skill instructions in skills/ad-creative-designer/SKILL.md exactly.
Read and follow the skill instructions in skills/gemini-ad-designer/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
@@ -211,11 +289,7 @@ Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/research_brief.md
You MUST produce TWO SETS of image assets:
---
## SET 1: Gemini AI-Generated Ads (NanoBanana MCP)
## Gemini AI-Generated Ads (NanoBanana MCP)
Use the NanoBanana MCP tools to create polished ad images.${screenshots?.length ? `
@@ -247,9 +321,31 @@ Generate exactly 4 Gemini ads with this mix:
IMPORTANT: For ads WITH people, show real-looking people naturally using the app — not stock photo poses. For ads WITHOUT people, focus on the phone/app in an environment (floating over house, on a counter, etc.)
---
Platform dimensions:
- Instagram Feed: 1080x1080
- Instagram Stories: 1080x1920
- Nextdoor Spotlight: 1200x1200
- Nextdoor Display: 1200x628
## SET 2: Canvas Design Posters (Museum-quality art)
Save ${outputDir}/ads/gemini/manifest.json listing all generated Gemini ads with fields: fileName, set ("gemini"), hook, platform, dimensions, headline, style ("with-people" or "without-people").
${buildStylePreferencesSection(appConfig)}
${campaignBrief}`,
"poster-ad-designer": `You are the Poster Ad Designer agent.
Read and follow the skill instructions in skills/poster-ad-designer/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/research_brief.md
## Canvas Design Posters (Museum-quality art)
Create poster ads using the /skill canvas-design approach. This is a TWO-STEP process:
@@ -276,6 +372,16 @@ Using the philosophy, create each poster as a .png file. For each:
8. The result should look like it could hang in a gallery or appear in a design magazine
9. The ${appName} app icon (${assetsDir}/icon.png) MUST appear in every poster, placed near the branding or CTA area. Use it as an <img> element in the HTML.
### CRITICAL Layout Rule: Phone Must NOT Cover Text
The phone mockup and text MUST occupy separate vertical zones — NEVER overlapping.
Use a three-zone vertical layout:
- **Top zone (15-30%):** Headline text only. No phone.
- **Middle zone (40-55%):** Phone mockup, centered. No text overlapping.
- **Bottom zone (15-25%):** Subtext, CTA, branding. No phone.
For 9:16 (1080x1920): headline top ~380px, phone middle ~420-1400px, CTA bottom ~400px.
For 1:1 (1080x1080): headline top ~200px, phone center max 45% width/500px tall, CTA bottom ~250px.
Before rendering, verify bounding boxes don't overlap. If they do, shrink the phone.
### MANDATORY Typography & Sizing Rules (Social Media Readability)
These are viewed on phones at arm's length. Text that looks fine on a monitor is INVISIBLE in a feed.
@@ -302,16 +408,17 @@ Generate at least 4 posters:
- 2x Instagram (1 feed 1080x1080, 1 stories 1080x1920)
- 2x TikTok cover images (1080x1920)
---
Platform dimensions:
- Instagram Feed: 1080x1080
- Instagram Stories: 1080x1920
- Nextdoor Spotlight: 1200x1200
- Nextdoor Display: 1200x628
Save ${outputDir}/ads/ad_manifest.json listing ALL generated ads from BOTH sets, with fields: fileName, set ("gemini" or "poster"), hook, platform, dimensions, headline.
Save ${outputDir}/ads/posters/manifest.json listing all generated poster ads with fields: fileName, set ("poster"), hook, platform, dimensions, headline.
After generating posters, also create the combined ${outputDir}/ads/ad_manifest.json listing ALL ads from both sets (read ${outputDir}/ads/gemini/manifest.json for the Gemini ads). Fields: fileName, set ("gemini" or "poster"), hook, platform, dimensions, headline, style.
${buildStylePreferencesSection(appConfig)}
${campaignBrief}`,
"video-ad-producer": `You are the Video Ad Producer agent.
@@ -425,7 +532,8 @@ const AGENT_LABELS: Record<string, string> = {
"trend-scout": "Trend Scout",
"marketing-research-agent": "Research Agent",
"script-writer": "Script Writer",
"ad-creative-designer": "Ad Creative Designer",
"gemini-ad-designer": "Gemini Ad Designer",
"poster-ad-designer": "Poster Ad Designer",
"video-ad-producer": "Video Ad Producer",
"copywriter-agent": "Copywriter",
"distribution-agent": "Distribution Agent",
@@ -472,6 +580,13 @@ function humanizeAgentError(agentName: string, code: number | null, stderr: stri
return `${label} failed unexpectedly`;
}
const AGENT_TOOLS: Record<string, string> = {
"gemini-ad-designer":
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image,mcp__nanobanana__set_aspect_ratio",
};
const DEFAULT_TOOLS =
"Read,Edit,Write,Bash,Grep,Glob";
export async function runAgentStep(
agentName: string,
prompt: string,
@@ -479,6 +594,7 @@ export async function runAgentStep(
env: Record<string, string>
): Promise<{ output: string }> {
return new Promise((resolve, reject) => {
const allowedTools = AGENT_TOOLS[agentName] || DEFAULT_TOOLS;
const args = [
"-p",
prompt,
@@ -486,7 +602,7 @@ export async function runAgentStep(
"stream-json",
"--verbose",
"--allowedTools",
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image",
allowedTools,
];
const claude = spawn("claude", args, {
@@ -736,7 +852,7 @@ export async function sendChatMessage(
"stream-json",
"--verbose",
"--allowedTools",
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image",
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image,mcp__nanobanana__set_aspect_ratio",
];
if (sessionId) args.push("--resume", sessionId);
+74 -44
View File
@@ -1,16 +1,16 @@
import sharp from "sharp";
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 }> = {
"instagram-feed": { width: 1080, height: 1080, label: "Instagram Feed" },
"instagram-stories": { width: 1080, height: 1920, label: "Instagram Stories" },
tiktok: { width: 1080, height: 1920, label: "TikTok" },
"nextdoor-spotlight": { width: 1200, height: 1200, label: "Nextdoor Spotlight" },
"nextdoor-display": { width: 1200, height: 628, label: "Nextdoor Display" },
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[] {
@@ -24,34 +24,80 @@ export function getPlatformFormat(key: string) {
return PLATFORM_FORMATS[key] || null;
}
/**
* Resize an image to target platform dimensions using Sharp.
* Uses cover fit + center crop for aspect ratio changes.
*/
export async function resizeImage(
sourcePath: string,
targetFormat: string,
outputDir: string
): Promise<{ filePath: string; fileName: string; dimensions: string; platform: string }> {
const fmt = PLATFORM_FORMATS[targetFormat];
if (!fmt) throw new Error(`Unknown format: ${targetFormat}`);
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 fileName = `${sourceBase}_${targetFormat}_${fmt.width}x${fmt.height}.png`;
const outputPath = path.join(outputDir, fileName);
const fullOutputPath = path.join(PIPELINE_ROOT, outputPath);
const fullSourcePath = path.join(PIPELINE_ROOT, sourcePath);
const env = await loadPipelineEnv();
await sharp(fullSourcePath)
.resize(fmt.width, fmt.height, { fit: "cover", position: "centre" })
.png()
.toFile(fullOutputPath);
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 }>;
const platform = targetFormat.split("-")[0];
// Resolve absolute paths so the agent doesn't have to guess
const absPipelineRoot = path.resolve(PIPELINE_ROOT);
const absSourcePath = path.join(absPipelineRoot, sourcePath);
return { filePath: outputPath, fileName, dimensions: `${fmt.width}x${fmt.height}`, platform };
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],
}));
}
/**
@@ -77,19 +123,3 @@ Rules:
const { output } = await runAgentStep("caption-retone", prompt, PIPELINE_ROOT, {});
return output.trim();
}
/**
* Repurpose an image asset to specific platform formats.
*/
export async function repurposeImage(
sourcePath: string,
targetFormats: string[],
outputDir: string
): Promise<Array<{ filePath: string; fileName: string; dimensions: string; platform: string }>> {
const results = [];
for (const fmt of targetFormats) {
const result = await resizeImage(sourcePath, fmt, outputDir);
results.push(result);
}
return results;
}
+48 -3
View File
@@ -12,6 +12,29 @@ interface ScannedFile {
metadata: string | null;
}
/** Truncate a JSON string to maxLen while keeping it valid JSON. */
function safeJsonTruncate(json: string, maxLen: number): string {
if (json.length <= maxLen) return json;
try {
const parsed = JSON.parse(json);
// Try to produce a shorter version by re-stringifying with key limits
const trimmed = JSON.stringify(parsed, (_key, value) => {
if (typeof value === "string" && value.length > 200) {
return value.slice(0, 200) + "…";
}
if (Array.isArray(value) && value.length > 5) {
return value.slice(0, 5);
}
return value;
});
if (trimmed.length <= maxLen) return trimmed;
// Still too long — return a minimal summary
return JSON.stringify({ _truncated: true, _originalLength: json.length });
} catch {
return JSON.stringify({ _truncated: true, _originalLength: json.length });
}
}
const FORMAT_TO_TYPE: Record<string, string> = {
png: "image",
jpg: "image",
@@ -65,13 +88,13 @@ function loadMetadata(fullPath: string, format: string): string | null {
if (Array.isArray(parsed)) {
return JSON.stringify({ captions: parsed.slice(0, 3), totalVariations: parsed.length });
}
return content.slice(0, 2000);
return safeJsonTruncate(content, 2000);
}
// For media files, look for adjacent JSON with same name
const jsonPath = fullPath.replace(/\.[^.]+$/, ".json");
if (existsSync(jsonPath)) {
return readFileSync(jsonPath, "utf-8").slice(0, 2000);
return safeJsonTruncate(readFileSync(jsonPath, "utf-8"), 2000);
}
// Look for manifest in same directory
@@ -85,7 +108,10 @@ function loadMetadata(fullPath: string, format: string): string | null {
const entry = manifest.find((e: { fileName?: string; file?: string }) =>
e.fileName === fileName || e.file === fileName
);
if (entry) return JSON.stringify(entry);
if (entry) {
// Ensure style field is preserved in metadata
return JSON.stringify(entry);
}
}
}
@@ -122,6 +148,25 @@ function scanDirectory(dir: string, baseDir: string): ScannedFile[] {
const ext = path.extname(entry).toLowerCase().slice(1);
if (!ext || ext === "gitkeep") continue;
// Only ingest deliverable files — skip source/build artifacts
const ASSET_EXTENSIONS = new Set([
"png", "jpg", "jpeg", "webp", "gif", // images
"mp4", "webm", // videos
]);
const CONTENT_EXTENSIONS = new Set([
"json", "md", "txt", // copy/scripts/research
]);
// Skip HTML source files, render scripts, and build tools
if (!ASSET_EXTENSIONS.has(ext) && !CONTENT_EXTENSIONS.has(ext)) continue;
// Skip known build/tool artifacts
const SKIP_FILES = new Set([
"tavily_search.mjs", "render_posters.mjs", "design_philosophy.md",
]);
if (SKIP_FILES.has(entry)) continue;
// Skip HTML source files in ads/ (they're build artifacts, not deliverables)
const relativePath0 = path.relative(baseDir, fullPath);
if (ext === "html" && relativePath0.includes("/ads/")) continue;
const relativePath = path.relative(baseDir, fullPath);
const type = inferTypeFromPath(relativePath, ext);
const metadata = loadMetadata(fullPath, ext);