feat: complete marketing command center with pipeline, UI, and asset generation
- 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>
This commit is contained in:
+184
@@ -0,0 +1,184 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user