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:
Trey t
2026-03-23 21:05:26 -05:00
parent 6b08cfb73a
commit 66c2bbec8b
113 changed files with 12741 additions and 138 deletions
+184
View File
@@ -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 };
}