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
+108 -15
View File
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Play, Copy, Sparkles, Send } from "lucide-react";
import { Play, Copy, Sparkles, Send, ThumbsUp, ThumbsDown } from "lucide-react";
import { RepurposeModal } from "./repurpose-modal";
import { VariationModal } from "./variation-modal";
import { ThumbsDownModal } from "./thumbs-down-modal";
interface Asset {
id: string;
@@ -27,6 +28,7 @@ interface AssetCardProps {
selected?: boolean;
onSelect?: (id: string) => void;
onPushToPostiz?: (assetIds: string[]) => void;
onRefresh?: () => void;
}
export function AssetCard({
@@ -34,11 +36,21 @@ export function AssetCard({
selected,
onSelect,
onPushToPostiz,
onRefresh,
}: AssetCardProps) {
const [repurposeOpen, setRepurposeOpen] = useState(false);
const [variationOpen, setVariationOpen] = useState(false);
const [thumbsDownOpen, setThumbsDownOpen] = useState(false);
const [vote, setVote] = useState<"up" | "down" | null>(null);
const metadata = asset.metadata ? JSON.parse(asset.metadata) : {};
let metadata: Record<string, unknown> = {};
if (asset.metadata) {
try {
metadata = JSON.parse(asset.metadata);
} catch {
// metadata may be truncated — ignore parse errors
}
}
const isImage = asset.type === "image" || asset.format === "png" || asset.format === "jpg";
const isVideo = asset.type === "video" || asset.format === "mp4";
const fileSrc = `/api/files/${asset.filePath}`;
@@ -54,6 +66,34 @@ export function AssetCard({
? "Playwright"
: null;
const isVisual = isImage || isVideo;
useEffect(() => {
if (!isVisual) return;
fetch(`/api/assets/${asset.id}/preference`)
.then((r) => r.json())
.then((data) => setVote(data.vote ?? null))
.catch(() => {});
}, [asset.id, isVisual]);
async function handleThumbsUp() {
const newVote = vote === "up" ? null : "up";
setVote(newVote); // Optimistic
try {
const res = await fetch(`/api/assets/${asset.id}/preference`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote: "up" }),
});
if (res.ok) {
const data = await res.json();
setVote(data.vote);
}
} catch (err) {
console.error("Preference API failed:", err);
}
}
const sourceColors: Record<string, string> = {
Gemini: "text-purple-600 border-purple-200 bg-purple-50",
"Canvas Design": "text-amber-600 border-amber-200 bg-amber-50",
@@ -146,7 +186,7 @@ export function AssetCard({
)}
</div>
{metadata.caption && (
{typeof metadata.caption === "string" && (
<p className="text-xs text-muted-foreground line-clamp-2">
{metadata.caption}
</p>
@@ -172,18 +212,61 @@ export function AssetCard({
)}
</div>
{/* Style Preference Buttons */}
{isVisual && (
<div className="flex items-center gap-1.5">
<button
onClick={handleThumbsUp}
className={`flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors ${
vote === "up"
? "border-green-300 bg-green-50 text-green-700"
: "border-border text-muted-foreground hover:bg-muted/50"
}`}
>
<ThumbsUp className="h-3 w-3" />
{vote === "up" ? "Liked" : "Like"}
</button>
<button
onClick={() => {
if (vote === "down") {
// Toggle off — optimistic
setVote(null);
fetch(`/api/assets/${asset.id}/preference`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote: "down" }),
})
.then((r) => r.json())
.then((data) => setVote(data.vote))
.catch(() => {});
} else {
setThumbsDownOpen(true);
}
}}
className={`flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors ${
vote === "down"
? "border-red-300 bg-red-50 text-red-700"
: "border-border text-muted-foreground hover:bg-muted/50"
}`}
>
<ThumbsDown className="h-3 w-3" />
{vote === "down" ? "Disliked" : "Dislike"}
</button>
</div>
)}
{/* Actions */}
{(isImage || isVideo) && (
<div className="flex gap-2 flex-wrap">
<div className="flex flex-col gap-1.5">
{onPushToPostiz && (
<Button
size="sm"
variant="outline"
className="flex-1 min-w-0 overflow-hidden"
className="w-full"
onClick={() => onPushToPostiz([asset.id])}
>
<Send className="h-3 w-3 shrink-0" />
<span className="truncate">Postiz</span>
<Send className="h-3 w-3" />
Push to Postiz
</Button>
)}
{isImage && (
@@ -191,20 +274,20 @@ export function AssetCard({
<Button
size="sm"
variant="outline"
className="flex-1 min-w-0 overflow-hidden"
className="w-full"
onClick={() => setRepurposeOpen(true)}
>
<Copy className="h-3 w-3 shrink-0" />
<span className="truncate">Repurpose</span>
<Copy className="h-3 w-3" />
Repurpose
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 min-w-0 overflow-hidden"
className="w-full"
onClick={() => setVariationOpen(true)}
>
<Sparkles className="h-3 w-3 shrink-0" />
<span className="truncate">Variations</span>
<Sparkles className="h-3 w-3" />
Spawn Variations
</Button>
</>
)}
@@ -216,7 +299,10 @@ export function AssetCard({
{repurposeOpen && (
<RepurposeModal
assetId={asset.id}
onClose={() => setRepurposeOpen(false)}
onClose={() => {
setRepurposeOpen(false);
onRefresh?.();
}}
/>
)}
{variationOpen && (
@@ -226,6 +312,13 @@ export function AssetCard({
onClose={() => setVariationOpen(false)}
/>
)}
{thumbsDownOpen && (
<ThumbsDownModal
assetId={asset.id}
onClose={() => setThumbsDownOpen(false)}
onSubmitted={() => setVote("down")}
/>
)}
</div>
);
}
+5 -3
View File
@@ -30,7 +30,7 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState({
platform: "all",
type: "all",
type: "media",
});
const [search, setSearch] = useState("");
const [sort, setSort] = useState("newest");
@@ -103,9 +103,10 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
setFilters((f) => ({ ...f, type: e.target.value }))
}
>
<option value="media">Images & Videos</option>
<option value="all">All Types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="image">Images Only</option>
<option value="video">Videos Only</option>
<option value="copy">Copy</option>
<option value="script">Scripts</option>
</select>
@@ -156,6 +157,7 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
selected={selectedIds.has(asset.id)}
onSelect={toggleSelect}
onPushToPostiz={onPushToPostiz}
onRefresh={fetchAssets}
/>
))}
</div>
+31 -11
View File
@@ -13,7 +13,24 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ImagePlus, X, Loader2 } from "lucide-react";
import { ImagePlus, X, Loader2, Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
function InfoTip({ text }: { text: string }) {
return (
<Tooltip>
<TooltipTrigger className="inline-flex text-muted-foreground hover:text-foreground transition-colors ml-1 align-middle">
<Info className="h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>{text}</TooltipContent>
</Tooltip>
);
}
export const PLATFORMS = ["instagram", "tiktok", "nextdoor"] as const;
export const GOALS = [
@@ -221,6 +238,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
}
return (
<TooltipProvider>
<Card>
<CardHeader>
<CardTitle>{mode === "edit" ? "Edit Campaign" : "New Campaign"}</CardTitle>
@@ -233,7 +251,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Campaign Name</Label>
<Label htmlFor="name">Campaign Name <InfoTip text="A descriptive name for this campaign. Used to organize outputs and label generated assets." /></Label>
<Input
id="name"
name="name"
@@ -244,7 +262,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label>Platforms</Label>
<Label>Platforms <InfoTip text="Which social platforms to generate content for. Each platform gets platform-specific ad dimensions, captions, and video styles." /></Label>
<div className="flex gap-3">
{PLATFORMS.map((platform) => (
<button
@@ -264,7 +282,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label>Campaign Goal</Label>
<Label>Campaign Goal <InfoTip text="The primary objective shapes the tone, CTA, and creative direction of all generated content." /></Label>
<input type="hidden" name="goal" value={selectedGoal} />
<div className="grid gap-2">
{GOALS.map((goal) => (
@@ -286,7 +304,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label htmlFor="keyMessage">Key Message</Label>
<Label htmlFor="keyMessage">Key Message <InfoTip text="The core value proposition. This drives every headline, hook, and CTA the AI generates." /></Label>
<Textarea
id="keyMessage"
name="keyMessage"
@@ -298,7 +316,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label>App Screenshots (optional)</Label>
<Label>App Screenshots (optional) <InfoTip text="Real app screenshots become reference images for Gemini. The AI places them in phone mockups and uses them as the hero visual in ads." /></Label>
<p className="text-xs text-muted-foreground">
Upload screenshots of the feature you want to showcase. These will
be incorporated into generated ads.
@@ -362,7 +380,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label htmlFor="socialProof">Social Proof</Label>
<Label htmlFor="socialProof">Social Proof <InfoTip text="Stats, ratings, or testimonials that build trust. Appears in ad copy and video overlays." /></Label>
<Textarea
id="socialProof"
name="socialProof"
@@ -373,7 +391,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label htmlFor="targetAudience">Target Audience</Label>
<Label htmlFor="targetAudience">Target Audience <InfoTip text="Who are we talking to? Influences tone, pain points, and hooks. Be specific — age, interests, situation." /></Label>
<Textarea
id="targetAudience"
name="targetAudience"
@@ -384,7 +402,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label htmlFor="visualDirection">Visual Direction</Label>
<Label htmlFor="visualDirection">Visual Direction <InfoTip text="Sets the overall aesthetic for generated images and videos. Affects color treatment, layout style, and mood." /></Label>
<select
id="visualDirection"
name="visualDirection"
@@ -400,7 +418,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label htmlFor="competitorApps">Competitor Apps (optional)</Label>
<Label htmlFor="competitorApps">Competitor Apps (optional) <InfoTip text="Apps you're competing with. The research agent analyzes their messaging to differentiate yours." /></Label>
<Input
id="competitorApps"
name="competitorApps"
@@ -410,7 +428,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label htmlFor="variations">Variations Per Platform</Label>
<Label htmlFor="variations">Variations Per Platform <InfoTip text="Number of unique hook angles to generate per platform. More variations = more A/B testing options." /></Label>
<Input
id="variations"
name="variations"
@@ -431,6 +449,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
/>
<Label htmlFor="useTrendReport" className="font-normal">
Use latest trend report for hook inspiration
<InfoTip text="When checked, the trend scout agent runs first and feeds current social media trends into the script writer for timely hooks." />
</Label>
</div>
@@ -446,5 +465,6 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</form>
</CardContent>
</Card>
</TooltipProvider>
);
}
+5 -3
View File
@@ -28,7 +28,7 @@ export function RepurposeModal({ assetId, onClose }: RepurposeModalProps) {
const [available, setAvailable] = useState<string[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{ created: number } | null>(null);
const [result, setResult] = useState<{ formats?: string[] } | null>(null);
useEffect(() => {
fetch(`/api/assets/${assetId}/repurpose`)
@@ -73,8 +73,10 @@ export function RepurposeModal({ assetId, onClose }: RepurposeModalProps) {
{result ? (
<div className="py-4 text-center">
<p className="text-lg font-semibold">{result.created} asset{result.created !== 1 ? "s" : ""} created</p>
<p className="text-sm text-muted-foreground mt-1">Check the Asset Library to review them.</p>
<p className="text-lg font-semibold">Repurposing to {result.formats?.length || 0} format{(result.formats?.length || 0) !== 1 ? "s" : ""}</p>
<p className="text-sm text-muted-foreground mt-1">
Gemini is regenerating the ad at each new size. New assets will appear in the Asset Library when ready.
</p>
<DialogFooter>
<Button onClick={onClose}>Done</Button>
</DialogFooter>
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const REASON_TAGS = [
"Too dark",
"Bad composition",
"Stock photo feel",
"Wrong colors",
"Too busy",
"Off brand",
];
interface ThumbsDownModalProps {
assetId: string;
onClose: () => void;
onSubmitted: (vote: "down") => void;
}
export function ThumbsDownModal({ assetId, onClose, onSubmitted }: ThumbsDownModalProps) {
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const [freeform, setFreeform] = useState("");
const [loading, setLoading] = useState(false);
function toggleTag(tag: string) {
setSelectedTags((prev) => {
const next = new Set(prev);
if (next.has(tag)) next.delete(tag);
else next.add(tag);
return next;
});
}
async function handleSubmit() {
setLoading(true);
const parts = [...selectedTags];
if (freeform.trim()) parts.push(freeform.trim());
const reason = parts.join("; ") || "Not preferred";
// Optimistic update — show disliked immediately
onSubmitted("down");
try {
const res = await fetch(`/api/assets/${assetId}/preference`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote: "down", reason }),
});
if (!res.ok) {
console.error("Preference API error:", res.status, await res.text());
}
} catch (err) {
console.error("Preference API failed:", err);
}
setLoading(false);
onClose();
}
return (
<Dialog open onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>What didn&apos;t you like?</DialogTitle>
<DialogDescription>
Your feedback helps the AI avoid this style in future generations.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="flex flex-wrap gap-2">
{REASON_TAGS.map((tag) => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`rounded-full border px-3 py-1.5 text-sm transition-colors ${
selectedTags.has(tag)
? "border-red-300 bg-red-50 text-red-700"
: "border-border bg-background text-muted-foreground hover:bg-muted/50"
}`}
>
{tag}
</button>
))}
</div>
<textarea
placeholder="Anything else? (optional)"
value={freeform}
onChange={(e) => setFreeform(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
rows={2}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
variant="destructive"
onClick={handleSubmit}
disabled={loading}
>
{loading ? "Saving..." : "Submit Feedback"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}