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:
Trey t
2026-05-03 20:28:07 -05:00
parent b318798ca7
commit 807dfc539b
40 changed files with 3089 additions and 232 deletions
+1
View File
@@ -43,6 +43,7 @@ export async function PATCH(
brandIdentity: body.brandIdentity ?? undefined,
productInfo: body.productInfo ?? undefined,
platformGuidelines: body.platformGuidelines ?? undefined,
stylePreferences: body.stylePreferences ?? undefined,
},
});
+156
View File
@@ -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 });
}
+52 -35
View File
@@ -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 });
}
+1
View File
@@ -55,6 +55,7 @@ export async function POST(
brandIdentity: app.brandIdentity,
productInfo: app.productInfo,
platformGuidelines: app.platformGuidelines,
stylePreferences: app.stylePreferences,
};
}
+5 -1
View File
@@ -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) {
+1
View File
@@ -42,6 +42,7 @@ export async function POST(
brandIdentity: app.brandIdentity,
productInfo: app.productInfo,
platformGuidelines: app.platformGuidelines,
stylePreferences: app.stylePreferences,
};
}