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>
230 lines
7.3 KiB
TypeScript
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 };
|
|
}
|