Files
Trey t 807dfc539b 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>
2026-05-03 20:28:07 -05:00

230 lines
7.3 KiB
TypeScript

import { readdirSync, readFileSync, statSync, existsSync } from "fs";
import path from "path";
import { prisma } from "./prisma";
interface ScannedFile {
filePath: string;
fileName: string;
type: string;
platform: string | null;
format: string;
dimensions: string | null;
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",
jpeg: "image",
webp: "image",
gif: "image",
mp4: "video",
webm: "video",
json: "copy",
txt: "copy",
md: "research",
html: "research",
};
function inferPlatform(fileName: string, filePath: string): string | null {
const lower = (fileName + filePath).toLowerCase();
if (lower.includes("instagram") || lower.includes("ig_") || lower.includes("ig ")) return "instagram";
if (lower.includes("tiktok") || lower.includes("tt_") || lower.includes("tik_tok")) return "tiktok";
if (lower.includes("nextdoor") || lower.includes("nd_")) return "nextdoor";
return null;
}
function inferDimensions(fileName: string): string | null {
const match = fileName.match(/(\d{3,4})x(\d{3,4})/);
return match ? `${match[1]}x${match[2]}` : null;
}
function inferTypeFromPath(filePath: string, format: string): string {
const lower = filePath.toLowerCase();
if (lower.includes("/ads/")) return "image";
if (lower.includes("/video/")) return "video";
if (lower.includes("/copy/")) return "copy";
if (lower.includes("/scripts/")) return "script";
return FORMAT_TO_TYPE[format] || "research";
}
/**
* Try to read metadata from adjacent JSON files or manifest.
* For an image "instagram_feed_hook_a_1080x1080.png", look for:
* - "instagram_feed_hook_a_1080x1080.json" (same name, .json ext)
* - "ad_manifest.json" in same directory
* For copy JSON files, read the file itself as metadata.
*/
function loadMetadata(fullPath: string, format: string): string | null {
try {
// For JSON files, read the content as metadata
if (format === "json") {
const content = readFileSync(fullPath, "utf-8");
const parsed = JSON.parse(content);
// Extract caption/summary if it's an array of captions
if (Array.isArray(parsed)) {
return JSON.stringify({ captions: parsed.slice(0, 3), totalVariations: parsed.length });
}
return safeJsonTruncate(content, 2000);
}
// For media files, look for adjacent JSON with same name
const jsonPath = fullPath.replace(/\.[^.]+$/, ".json");
if (existsSync(jsonPath)) {
return safeJsonTruncate(readFileSync(jsonPath, "utf-8"), 2000);
}
// Look for manifest in same directory
const dir = path.dirname(fullPath);
const manifestPath = path.join(dir, "ad_manifest.json");
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
const fileName = path.basename(fullPath);
// Find this file's entry in the manifest
if (Array.isArray(manifest)) {
const entry = manifest.find((e: { fileName?: string; file?: string }) =>
e.fileName === fileName || e.file === fileName
);
if (entry) {
// Ensure style field is preserved in metadata
return JSON.stringify(entry);
}
}
}
// Look for scene_plans.json for video files
if (format === "mp4" || format === "webm") {
const scenePlansPath = path.join(dir, "scene_plans.json");
if (existsSync(scenePlansPath)) {
const plans = JSON.parse(readFileSync(scenePlansPath, "utf-8"));
const fileName = path.basename(fullPath);
if (plans[fileName]) return JSON.stringify(plans[fileName]);
}
}
} catch {
// Metadata loading is best-effort
}
return null;
}
function scanDirectory(dir: string, baseDir: string): ScannedFile[] {
const files: ScannedFile[] = [];
try {
const entries = readdirSync(dir);
for (const entry of entries) {
if (entry.startsWith(".")) continue;
const fullPath = path.join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
files.push(...scanDirectory(fullPath, baseDir));
} else {
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);
files.push({
filePath: relativePath,
fileName: entry,
type,
platform: inferPlatform(entry, relativePath),
format: ext,
dimensions: inferDimensions(entry),
metadata,
});
}
}
} catch {
// Directory doesn't exist or can't be read
}
return files;
}
export async function scanOutputDirectory(
campaignId: string,
outputPath: string,
pipelineRoot: string
) {
const fullOutputPath = path.join(pipelineRoot, outputPath);
const files = scanDirectory(fullOutputPath, pipelineRoot);
let created = 0;
for (const file of files) {
const existing = await prisma.asset.findFirst({
where: {
campaignId,
filePath: file.filePath,
},
});
if (!existing) {
await prisma.asset.create({
data: {
campaignId,
type: file.type,
platform: file.platform,
format: file.format,
filePath: file.filePath,
fileName: file.fileName,
dimensions: file.dimensions,
metadata: file.metadata,
status: "draft",
},
});
created++;
}
}
return { scanned: files.length, created };
}