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:
@@ -28,4 +28,5 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
signIn: "/login",
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
trustHost: true,
|
||||
});
|
||||
|
||||
+132
-16
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user