66c2bbec8b
- Dashboard with campaign management, asset gallery, and publishing queue - 7-agent pipeline: trend scout, research, scripts, ad creative, video, copy, distribution - Campaign form with screenshot upload, goal picker, platform selection - Campaign detail view with Details/Pipeline/Assets/Chat tabs - Two-set image generation: Gemini AI (NanoBanana MCP) + Canvas Design posters - Remotion video rendering with phone.png frame and real screenshot alignment - honeyDue branding: blue #0079FF, orange #FF9400, Inter font, warm off-white - Asset cards with source badges (Gemini/Canvas/Remotion/Playwright) - Markdown/JSON render endpoint for viewing pipeline outputs as HTML - Settings page with Tavily, Gemini, Postiz, Nextdoor integration management - Claude Chat for campaign feedback loop with streaming SSE - Postiz publishing modal with scheduling - Auth with NextAuth credentials + JWT sessions - SQLite via Prisma with better-sqlite3 adapter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
185 lines
5.4 KiB
TypeScript
185 lines
5.4 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;
|
|
}
|
|
|
|
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 content.slice(0, 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);
|
|
}
|
|
|
|
// 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) 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;
|
|
|
|
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 };
|
|
}
|