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>
This commit is contained in:
@@ -64,7 +64,7 @@ export default function AppsPage() {
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{apps.map((app) => (
|
||||
<Link key={app.id} href={`/apps/${app.slug}`}>
|
||||
<Card className="transition-shadow hover:shadow-md">
|
||||
<Card className="h-full transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
@@ -81,22 +81,16 @@ export default function AppsPage() {
|
||||
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">
|
||||
{app.description || "No description"}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: app.primaryColor }}
|
||||
/>
|
||||
<span>{app.primaryColor}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: app.accentColor }}
|
||||
/>
|
||||
<span>{app.accentColor}</span>
|
||||
</div>
|
||||
<span className="ml-auto text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: app.primaryColor }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: app.accentColor }}
|
||||
/>
|
||||
<span className="ml-auto">
|
||||
{app._count.campaigns} campaign{app._count.campaigns !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,13 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Plus, Info } from "lucide-react";
|
||||
|
||||
interface Campaign {
|
||||
id: string;
|
||||
@@ -30,6 +36,25 @@ const statusColors: Record<string, "default" | "secondary" | "destructive" | "ou
|
||||
published: "default",
|
||||
};
|
||||
|
||||
const statusTooltips: Record<string, string> = {
|
||||
draft: "Campaign is configured but hasn't been launched yet. Click to edit and launch.",
|
||||
running: "The AI pipeline is actively generating assets for this campaign.",
|
||||
review: "All agents finished. Assets are ready for your review before publishing.",
|
||||
approved: "Assets have been approved and are ready to publish.",
|
||||
published: "Campaign content has been pushed to social platforms.",
|
||||
};
|
||||
|
||||
function Tip({ children, content }: { children: React.ReactNode; content: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="inline-flex items-center">
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CampaignsPage() {
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
|
||||
@@ -41,49 +66,72 @@ export default function CampaignsPage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">Campaigns</h1>
|
||||
<Link href="/campaigns/new" className={buttonVariants()}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Campaign
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{campaigns.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No campaigns yet. Create your first one to get started.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{campaigns.map((campaign) => {
|
||||
const platforms = JSON.parse(campaign.platforms) as string[];
|
||||
return (
|
||||
<Link key={campaign.id} href={`/campaigns/${campaign.id}`}>
|
||||
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{campaign.name}</CardTitle>
|
||||
<Badge variant={statusColors[campaign.status] || "secondary"}>
|
||||
{campaign.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{platforms.join(", ")} ·{" "}
|
||||
{campaign._count.assets} assets ·{" "}
|
||||
{new Date(campaign.createdAt).toLocaleDateString()}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<TooltipProvider>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold">Campaigns</h1>
|
||||
<Tip content="Campaigns run the AI pipeline to generate ads, videos, copy, and scripts for your app across platforms.">
|
||||
<Info className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
</Tip>
|
||||
</div>
|
||||
<Link href="/campaigns/new" className={buttonVariants()}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Campaign
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{campaigns.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No campaigns yet. Create your first one to get started.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{campaigns.map((campaign) => {
|
||||
const platforms = JSON.parse(campaign.platforms) as string[];
|
||||
return (
|
||||
<Link key={campaign.id} href={`/campaigns/${campaign.id}`}>
|
||||
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{campaign.name}</CardTitle>
|
||||
<Tip content={statusTooltips[campaign.status] || "Campaign status"}>
|
||||
<Badge variant={statusColors[campaign.status] || "secondary"}>
|
||||
{campaign.status}
|
||||
</Badge>
|
||||
</Tip>
|
||||
</div>
|
||||
<CardDescription className="flex items-center gap-1">
|
||||
<Tip content="Target platforms where ads and content will be generated and optimized.">
|
||||
<span className="underline decoration-dotted underline-offset-4 cursor-help">
|
||||
{platforms.join(", ")}
|
||||
</span>
|
||||
</Tip>
|
||||
<span>·</span>
|
||||
<Tip content="Total generated files — images, videos, copy, and scripts produced by the AI pipeline.">
|
||||
<span className="underline decoration-dotted underline-offset-4 cursor-help">
|
||||
{campaign._count.assets} assets
|
||||
</span>
|
||||
</Tip>
|
||||
<span>·</span>
|
||||
<Tip content="Date this campaign was created.">
|
||||
<span className="underline decoration-dotted underline-offset-4 cursor-help">
|
||||
{new Date(campaign.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</Tip>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export async function PATCH(
|
||||
brandIdentity: body.brandIdentity ?? undefined,
|
||||
productInfo: body.productInfo ?? undefined,
|
||||
platformGuidelines: body.platformGuidelines ?? undefined,
|
||||
stylePreferences: body.stylePreferences ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface LikedEntry {
|
||||
assetId: string;
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
dimensions: string | null;
|
||||
platform: string | null;
|
||||
style: string | null; // "with-people" | "without-people" | null
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface DislikedEntry {
|
||||
assetId: string;
|
||||
reason: string;
|
||||
fileName: string;
|
||||
dimensions: string | null;
|
||||
platform: string | null;
|
||||
style: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface StylePreferences {
|
||||
liked: LikedEntry[];
|
||||
disliked: DislikedEntry[];
|
||||
}
|
||||
|
||||
function parsePreferences(raw: string | null): StylePreferences {
|
||||
if (!raw) return { liked: [], disliked: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
liked: Array.isArray(parsed.liked) ? parsed.liked : [],
|
||||
disliked: Array.isArray(parsed.disliked) ? parsed.disliked : [],
|
||||
};
|
||||
} catch {
|
||||
return { liked: [], disliked: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const asset = await prisma.asset.findUnique({
|
||||
where: { id },
|
||||
include: { campaign: { include: { app: true } } },
|
||||
});
|
||||
|
||||
if (!asset) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const app = asset.campaign?.app;
|
||||
if (!app) return Response.json({ vote: null });
|
||||
|
||||
const prefs = parsePreferences(app.stylePreferences);
|
||||
const isLiked = prefs.liked.some((e) => e.assetId === id);
|
||||
const isDisliked = prefs.disliked.some((e) => e.assetId === id);
|
||||
|
||||
return Response.json({ vote: isLiked ? "up" : isDisliked ? "down" : null });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { vote, reason } = body as { vote: "up" | "down"; reason?: string };
|
||||
|
||||
if (vote !== "up" && vote !== "down") {
|
||||
return Response.json({ error: "vote must be 'up' or 'down'" }, { status: 400 });
|
||||
}
|
||||
|
||||
const asset = await prisma.asset.findUnique({
|
||||
where: { id },
|
||||
include: { campaign: { include: { app: true } } },
|
||||
});
|
||||
|
||||
if (!asset) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const app = asset.campaign?.app;
|
||||
if (!app) {
|
||||
return Response.json({ error: "Asset has no associated app" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Extract style from asset metadata (set by ad_manifest.json during generation)
|
||||
let assetStyle: string | null = null;
|
||||
if (asset.metadata) {
|
||||
try {
|
||||
const meta = JSON.parse(asset.metadata);
|
||||
if (meta.style === "with-people" || meta.style === "without-people") {
|
||||
assetStyle = meta.style;
|
||||
}
|
||||
} catch { /* metadata isn't JSON or has no style */ }
|
||||
}
|
||||
|
||||
const prefs = parsePreferences(app.stylePreferences);
|
||||
|
||||
// Remove from both lists first (clean slate for this asset)
|
||||
prefs.liked = prefs.liked.filter((e) => e.assetId !== id);
|
||||
prefs.disliked = prefs.disliked.filter((e) => e.assetId !== id);
|
||||
|
||||
// Check if this was already the active vote (toggle off)
|
||||
const wasLiked = parsePreferences(app.stylePreferences).liked.some((e) => e.assetId === id);
|
||||
const wasDisliked = parsePreferences(app.stylePreferences).disliked.some((e) => e.assetId === id);
|
||||
const isToggleOff = (vote === "up" && wasLiked) || (vote === "down" && wasDisliked);
|
||||
|
||||
if (!isToggleOff) {
|
||||
if (vote === "up") {
|
||||
prefs.liked.push({
|
||||
assetId: id,
|
||||
filePath: asset.filePath,
|
||||
fileName: asset.fileName,
|
||||
dimensions: asset.dimensions || null,
|
||||
platform: asset.platform || null,
|
||||
style: assetStyle,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// Keep max ~10 liked
|
||||
if (prefs.liked.length > 10) {
|
||||
prefs.liked = prefs.liked.slice(-10);
|
||||
}
|
||||
} else {
|
||||
prefs.disliked.push({
|
||||
assetId: id,
|
||||
reason: reason || "Not preferred",
|
||||
fileName: asset.fileName,
|
||||
dimensions: asset.dimensions || null,
|
||||
platform: asset.platform || null,
|
||||
style: assetStyle,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// Keep max ~20 disliked
|
||||
if (prefs.disliked.length > 20) {
|
||||
prefs.disliked = prefs.disliked.slice(-20);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.app.update({
|
||||
where: { id: app.id },
|
||||
data: { stylePreferences: JSON.stringify(prefs) },
|
||||
});
|
||||
|
||||
const newVote = isToggleOff ? null : vote;
|
||||
return Response.json({ vote: newVote });
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { repurposeImage, retoneCaption, getAvailableFormats } from "@/lib/repurpose";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
@@ -36,44 +40,57 @@ export async function POST(
|
||||
}
|
||||
|
||||
const outputDir = `outputs/repurposed_${id.slice(0, 8)}`;
|
||||
const resized = await repurposeImage(asset.filePath, targetFormats, outputDir);
|
||||
const results = [];
|
||||
|
||||
for (const file of resized) {
|
||||
const originalMeta = asset.metadata ? JSON.parse(asset.metadata) : {};
|
||||
let newMeta = { ...originalMeta };
|
||||
// Launch async — Gemini generation takes time
|
||||
(async () => {
|
||||
try {
|
||||
const expectedFiles = await repurposeImage(
|
||||
asset.filePath,
|
||||
asset.dimensions,
|
||||
targetFormats,
|
||||
outputDir
|
||||
);
|
||||
|
||||
// Re-tone caption if platform changed and caption exists
|
||||
if (originalMeta.caption && asset.platform && file.platform !== asset.platform) {
|
||||
try {
|
||||
const newCaption = await retoneCaption(
|
||||
originalMeta.caption,
|
||||
asset.platform,
|
||||
file.platform
|
||||
);
|
||||
newMeta = { ...newMeta, caption: newCaption, originalCaption: originalMeta.caption };
|
||||
} catch {
|
||||
// Keep original caption if re-toning fails
|
||||
// Create DB records for files that were actually generated
|
||||
for (const file of expectedFiles) {
|
||||
const fullPath = path.join(PIPELINE_ROOT, file.filePath);
|
||||
if (!existsSync(fullPath)) continue;
|
||||
|
||||
const originalMeta = asset.metadata ? JSON.parse(asset.metadata) : {};
|
||||
let newMeta = { ...originalMeta };
|
||||
|
||||
if (originalMeta.caption && asset.platform && file.platform !== asset.platform) {
|
||||
try {
|
||||
const newCaption = await retoneCaption(
|
||||
originalMeta.caption,
|
||||
asset.platform,
|
||||
file.platform
|
||||
);
|
||||
newMeta = { ...newMeta, caption: newCaption, originalCaption: originalMeta.caption };
|
||||
} catch {
|
||||
// Keep original caption
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.asset.create({
|
||||
data: {
|
||||
campaignId: asset.campaignId,
|
||||
type: "image",
|
||||
platform: file.platform,
|
||||
format: "png",
|
||||
filePath: file.filePath,
|
||||
fileName: file.fileName,
|
||||
dimensions: file.dimensions,
|
||||
metadata: JSON.stringify(newMeta),
|
||||
status: "draft",
|
||||
parentAssetId: asset.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Repurpose failed for asset ${id}:`, err);
|
||||
}
|
||||
})();
|
||||
|
||||
const newAsset = await prisma.asset.create({
|
||||
data: {
|
||||
campaignId: asset.campaignId,
|
||||
type: "image",
|
||||
platform: file.platform,
|
||||
format: "png",
|
||||
filePath: file.filePath,
|
||||
fileName: file.fileName,
|
||||
dimensions: file.dimensions,
|
||||
metadata: JSON.stringify(newMeta),
|
||||
status: "draft",
|
||||
parentAssetId: asset.id,
|
||||
},
|
||||
});
|
||||
|
||||
results.push(newAsset);
|
||||
}
|
||||
|
||||
return Response.json({ created: results.length, assets: results });
|
||||
return Response.json({ status: "repurposing", formats: targetFormats });
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function POST(
|
||||
brandIdentity: app.brandIdentity,
|
||||
productInfo: app.productInfo,
|
||||
platformGuidelines: app.platformGuidelines,
|
||||
stylePreferences: app.stylePreferences,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,11 @@ export async function GET(request: Request) {
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (campaignId) where.campaignId = campaignId;
|
||||
if (type && type !== "all") where.type = type;
|
||||
if (type === "media") {
|
||||
where.type = { in: ["image", "video"] };
|
||||
} else if (type && type !== "all") {
|
||||
where.type = type;
|
||||
}
|
||||
if (platform && platform !== "all") where.platform = platform;
|
||||
if (status && status !== "all") where.status = status;
|
||||
if (search) {
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function POST(
|
||||
brandIdentity: app.brandIdentity,
|
||||
productInfo: app.productInfo,
|
||||
platformGuidelines: app.platformGuidelines,
|
||||
stylePreferences: app.stylePreferences,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user