diff --git a/app/api/assets/[id]/repurpose/route.ts b/app/api/assets/[id]/repurpose/route.ts new file mode 100644 index 0000000..ab238b0 --- /dev/null +++ b/app/api/assets/[id]/repurpose/route.ts @@ -0,0 +1,79 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { repurposeImage, retoneCaption, getAvailableFormats } from "@/lib/repurpose"; + +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 } }); + if (!asset) return Response.json({ error: "Not found" }, { status: 404 }); + + const formats = getAvailableFormats(asset.dimensions); + return Response.json({ formats, currentDimensions: asset.dimensions }); +} + +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 { formats: targetFormats } = body as { formats: string[] }; + + const asset = await prisma.asset.findUnique({ where: { id } }); + if (!asset) return Response.json({ error: "Not found" }, { status: 404 }); + + if (asset.type !== "image") { + return Response.json({ error: "Only image assets can be repurposed" }, { status: 400 }); + } + + 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 }; + + // 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 + } + } + + 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 }); +} diff --git a/app/api/assets/[id]/variations/route.ts b/app/api/assets/[id]/variations/route.ts new file mode 100644 index 0000000..716982e --- /dev/null +++ b/app/api/assets/[id]/variations/route.ts @@ -0,0 +1,67 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { spawnVariations } from "@/lib/variations"; +import type { AppConfig } from "@/lib/claude"; + +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 variations = await prisma.asset.findMany({ + where: { parentAssetId: id }, + orderBy: { createdAt: "desc" }, + }); + + return Response.json(variations); +} + +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 count = body.count || 5; + + const asset = await prisma.asset.findUnique({ + where: { id }, + include: { campaign: { include: { app: true } } }, + }); + + if (!asset) return Response.json({ error: "Not found" }, { status: 404 }); + if (asset.type !== "image") { + return Response.json({ error: "Only image assets can spawn variations" }, { status: 400 }); + } + + // Build AppConfig if campaign has an app + let appConfig: AppConfig | undefined; + if (asset.campaign?.app) { + const app = asset.campaign.app; + appConfig = { + name: app.name, + slug: app.slug, + primaryColor: app.primaryColor, + accentColor: app.accentColor, + darkBg: app.darkBg, + assetsDir: `apps/${app.slug}`, + brandIdentity: app.brandIdentity, + productInfo: app.productInfo, + platformGuidelines: app.platformGuidelines, + }; + } + + // Launch async — don't block the request + spawnVariations({ assetId: id, count, appConfig }).catch((err) => + console.error(`Variation spawning failed for asset ${id}:`, err) + ); + + return Response.json({ status: "spawning", count }); +} diff --git a/app/api/assets/route.ts b/app/api/assets/route.ts index 3a0e2fc..d540190 100644 --- a/app/api/assets/route.ts +++ b/app/api/assets/route.ts @@ -35,7 +35,10 @@ export async function GET(request: Request) { const assets = await prisma.asset.findMany({ where, orderBy: { createdAt: "desc" }, - include: { campaign: { select: { name: true } } }, + include: { + campaign: { select: { name: true } }, + parentAsset: { select: { id: true, fileName: true } }, + }, }); return Response.json(assets); diff --git a/components/asset-card.tsx b/components/asset-card.tsx index f6f4422..39af786 100644 --- a/components/asset-card.tsx +++ b/components/asset-card.tsx @@ -1,8 +1,11 @@ "use client"; +import { useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Check, X, Play } from "lucide-react"; +import { Check, X, Play, Copy, Sparkles } from "lucide-react"; +import { RepurposeModal } from "./repurpose-modal"; +import { VariationModal } from "./variation-modal"; interface Asset { id: string; @@ -16,6 +19,7 @@ interface Asset { status: string; createdAt: string; campaign?: { name: string }; + parentAsset?: { id: string; fileName: string } | null; } interface AssetCardProps { @@ -31,6 +35,9 @@ export function AssetCard({ selected, onSelect, }: AssetCardProps) { + const [repurposeOpen, setRepurposeOpen] = useState(false); + const [variationOpen, setVariationOpen] = useState(false); + const metadata = asset.metadata ? JSON.parse(asset.metadata) : {}; const isImage = asset.type === "image" || asset.format === "png" || asset.format === "jpg"; const isVideo = asset.type === "video" || asset.format === "mp4"; @@ -157,6 +164,12 @@ export function AssetCard({

)} + {asset.parentAsset && ( +

+ Derived from: {asset.parentAsset.fileName} +

+ )} +
{asset.campaign && {asset.campaign.name}} {asset.campaign && asset.createdAt && ·} @@ -173,32 +186,71 @@ export function AssetCard({ {/* Actions — only for images and videos */} {(isImage || isVideo) ? ( -
- - +
+
+ + +
+ {isImage && ( +
+ + +
+ )}
) : (

Auto-accepted

)}
+ + {/* Modals */} + {repurposeOpen && ( + setRepurposeOpen(false)} + /> + )} + {variationOpen && ( + setVariationOpen(false)} + /> + )}
); } diff --git a/components/asset-gallery.tsx b/components/asset-gallery.tsx index 1ed99e6..9220824 100644 --- a/components/asset-gallery.tsx +++ b/components/asset-gallery.tsx @@ -17,6 +17,7 @@ interface Asset { status: string; createdAt: string; campaign?: { name: string }; + parentAsset?: { id: string; fileName: string } | null; } interface AssetGalleryProps { diff --git a/components/repurpose-modal.tsx b/components/repurpose-modal.tsx new file mode 100644 index 0000000..828ca83 --- /dev/null +++ b/components/repurpose-modal.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +const FORMAT_LABELS: Record = { + "instagram-feed": { label: "Instagram Feed", dimensions: "1080x1080" }, + "instagram-stories": { label: "Instagram Stories", dimensions: "1080x1920" }, + tiktok: { label: "TikTok", dimensions: "1080x1920" }, + "nextdoor-spotlight": { label: "Nextdoor Spotlight", dimensions: "1200x1200" }, + "nextdoor-display": { label: "Nextdoor Display", dimensions: "1200x628" }, +}; + +interface RepurposeModalProps { + assetId: string; + onClose: () => void; +} + +export function RepurposeModal({ assetId, onClose }: RepurposeModalProps) { + const [available, setAvailable] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState<{ created: number } | null>(null); + + useEffect(() => { + fetch(`/api/assets/${assetId}/repurpose`) + .then((r) => r.json()) + .then((data) => { + setAvailable(data.formats || []); + setSelected(new Set(data.formats || [])); + }) + .catch(() => {}); + }, [assetId]); + + async function handleRepurpose() { + setLoading(true); + const res = await fetch(`/api/assets/${assetId}/repurpose`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ formats: Array.from(selected) }), + }); + const data = await res.json(); + setResult(data); + setLoading(false); + } + + function toggle(key: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + return ( + + + + Repurpose Asset + + Resize this image for other platforms. Captions will be re-toned to match each platform. + + + + {result ? ( +
+

{result.created} asset{result.created !== 1 ? "s" : ""} created

+

Check the Asset Library to review them.

+ + + +
+ ) : ( + <> +
+ {available.map((key) => { + const fmt = FORMAT_LABELS[key]; + if (!fmt) return null; + return ( + + ); + })} +
+ + + + + + )} +
+
+ ); +} diff --git a/components/variation-modal.tsx b/components/variation-modal.tsx new file mode 100644 index 0000000..c6ef85a --- /dev/null +++ b/components/variation-modal.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface VariationModalProps { + assetId: string; + assetName: string; + onClose: () => void; +} + +export function VariationModal({ assetId, assetName, onClose }: VariationModalProps) { + const [count, setCount] = useState(5); + const [loading, setLoading] = useState(false); + const [launched, setLaunched] = useState(false); + + async function handleSpawn() { + setLoading(true); + await fetch(`/api/assets/${assetId}/variations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ count }), + }); + setLoading(false); + setLaunched(true); + } + + function handleClose() { + setLaunched(false); + setCount(5); + onClose(); + } + + return ( + + + + Spawn Variations + + Generate new ads with the same emotional pattern as “{assetName}” but with different hook angles. + + + + {launched ? ( +
+

Spawning {count} variations

+

+ The AI pipeline is running. New assets will appear in the Asset Library when ready. +

+ + + +
+ ) : ( + <> +
+ + setCount(Math.max(1, Math.min(20, parseInt(e.target.value) || 5)))} + /> +

+ Each variation keeps the same visual style and CTA but explores a different hook angle. + Uses the original ad as a reference image for visual consistency. +

+
+ + + + + + )} +
+
+ ); +} diff --git a/docs/plans/2026-03-23-variation-spawner-and-repurposing.md b/docs/plans/2026-03-23-variation-spawner-and-repurposing.md new file mode 100644 index 0000000..0530f26 --- /dev/null +++ b/docs/plans/2026-03-23-variation-spawner-and-repurposing.md @@ -0,0 +1,912 @@ +# Variation Spawner & Content Repurposing Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Let users click an asset to spawn hook/visual variations (via Claude CLI + Gemini) and repurpose assets across platforms/formats (local resize + copy re-tone). + +**Architecture:** Two new features share a common pattern: take an existing asset, produce new assets linked to it via `parentAssetId`. Variation spawner runs a mini-pipeline (2 agents: ad-creative + copywriter). Content repurposing does local Sharp resizes for images, Remotion re-renders for video, and a Claude CLI call to re-tone copy. Both surface in the asset card as action buttons and produce results in a new output subfolder. + +**Tech Stack:** Sharp (already in Next.js), Remotion (existing), Claude CLI (existing `runAgentStep`), Prisma/SQLite. + +--- + +### Task 1: Schema — Add `parentAssetId` to Asset + +**Files:** +- Modify: `prisma/schema.prisma` (Asset model) + +**Step 1: Add self-referential relation to Asset model** + +In `prisma/schema.prisma`, add these fields to the `Asset` model after `postizMediaId`: + +```prisma +parentAssetId String? +parentAsset Asset? @relation("AssetVariations", fields: [parentAssetId], references: [id]) +variations Asset[] @relation("AssetVariations") +``` + +**Step 2: Push schema and regenerate** + +Run: +```bash +npx prisma db push +npx prisma generate +``` + +**Step 3: Commit** + +```bash +git add prisma/schema.prisma +git commit -m "feat: add parentAssetId self-relation to Asset for variations" +``` + +--- + +### Task 2: Repurpose library — `lib/repurpose.ts` + +Image resizing with Sharp, platform dimension mapping, copy re-toning with Claude CLI. + +**Files:** +- Create: `lib/repurpose.ts` + +**Step 1: Create the repurpose module** + +```typescript +import sharp from "sharp"; +import path from "path"; +import { mkdirSync, readFileSync, writeFileSync } from "fs"; +import { runAgentStep } from "./claude"; + +const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline"); + +// All supported platform formats with dimensions +const PLATFORM_FORMATS: Record = { + "instagram-feed": { width: 1080, height: 1080, label: "Instagram Feed" }, + "instagram-stories": { width: 1080, height: 1920, label: "Instagram Stories" }, + "tiktok": { width: 1080, height: 1920, label: "TikTok" }, + "nextdoor-spotlight": { width: 1200, height: 1200, label: "Nextdoor Spotlight" }, + "nextdoor-display": { width: 1200, height: 628, label: "Nextdoor Display" }, +}; + +export function getAvailableFormats(currentDimensions: string | null): string[] { + // Return formats that differ from the current one + return Object.keys(PLATFORM_FORMATS).filter((key) => { + const fmt = PLATFORM_FORMATS[key]; + return `${fmt.width}x${fmt.height}` !== currentDimensions; + }); +} + +export function getPlatformFormat(key: string) { + return PLATFORM_FORMATS[key] || null; +} + +/** + * Resize an image to target platform dimensions using Sharp. + * Uses cover fit + center crop for aspect ratio changes. + */ +export async function resizeImage( + sourcePath: string, + targetFormat: string, + outputDir: string +): Promise<{ filePath: string; fileName: string; dimensions: string; platform: string }> { + const fmt = PLATFORM_FORMATS[targetFormat]; + if (!fmt) throw new Error(`Unknown format: ${targetFormat}`); + + mkdirSync(path.join(PIPELINE_ROOT, outputDir), { recursive: true }); + + const sourceBase = path.basename(sourcePath, path.extname(sourcePath)); + const fileName = `${sourceBase}_${targetFormat}_${fmt.width}x${fmt.height}.png`; + const outputPath = path.join(outputDir, fileName); + const fullOutputPath = path.join(PIPELINE_ROOT, outputPath); + const fullSourcePath = path.join(PIPELINE_ROOT, sourcePath); + + await sharp(fullSourcePath) + .resize(fmt.width, fmt.height, { fit: "cover", position: "centre" }) + .png() + .toFile(fullOutputPath); + + const platform = targetFormat.split("-")[0]; // "instagram", "tiktok", "nextdoor" + + return { + filePath: outputPath, + fileName, + dimensions: `${fmt.width}x${fmt.height}`, + platform, + }; +} + +/** + * Re-tone a caption for a different platform using Claude CLI. + */ +export async function retoneCaption( + originalCaption: string, + originalPlatform: string, + targetPlatform: string +): Promise { + const prompt = `You are a social media copywriter. Adapt this ${originalPlatform} caption for ${targetPlatform}. + +Original caption: +${originalCaption} + +Rules: +- ${targetPlatform === "tiktok" ? "Raw, authentic tone. Short. Trending hashtags. Hook in first line." : ""} +- ${targetPlatform === "instagram" ? "Polished, aspirational. Hook → Value → CTA → Hashtags. 150-300 chars." : ""} +- ${targetPlatform === "nextdoor" ? "Warm, neighborly. No hashtags. Reference local/community context." : ""} +- Keep the core message but match the platform's voice. +- Return ONLY the new caption text, nothing else.`; + + const { output } = await runAgentStep( + "caption-retone", + prompt, + PIPELINE_ROOT, + {} + ); + + return output.trim(); +} + +/** + * Repurpose an image asset to all missing platform formats. + * Returns array of new file info for creating Asset records. + */ +export async function repurposeImage( + sourcePath: string, + currentDimensions: string | null, + outputDir: string +): Promise> { + const formats = getAvailableFormats(currentDimensions); + const results = []; + + for (const fmt of formats) { + const result = await resizeImage(sourcePath, fmt, outputDir); + results.push(result); + } + + return results; +} +``` + +**Step 2: Commit** + +```bash +git add lib/repurpose.ts +git commit -m "feat: add repurpose library with Sharp resize and caption re-toning" +``` + +--- + +### Task 3: Variation spawner library — `lib/variations.ts` + +Builds a variation prompt from an existing asset's DNA, runs a mini-pipeline (ad-creative + copywriter only). + +**Files:** +- Create: `lib/variations.ts` + +**Step 1: Create the variations module** + +```typescript +import { mkdirSync } from "fs"; +import path from "path"; +import { prisma } from "./prisma"; +import { runAgentStep } from "./claude"; +import { getAllSettings } from "./settings"; +import { scanOutputDirectory } from "./scanner"; +import type { AppConfig } from "./claude"; + +const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline"); + +interface VariationRequest { + assetId: string; + count: number; // how many variations to generate + appConfig?: AppConfig; +} + +/** + * Load API keys for the Claude subprocess. + */ +async function loadPipelineEnv(): Promise> { + const settings = await getAllSettings(); + const env: Record = {}; + if (settings.TAVILY_API_KEY) env.TAVILY_API_KEY = settings.TAVILY_API_KEY; + if (settings.GEMINI_API_KEY) env.GEMINI_API_KEY = settings.GEMINI_API_KEY; + return env; +} + +/** + * Extract the "DNA" from an asset — its hook, CTA, visual style, platform, dimensions. + */ +function extractAssetDNA(asset: { + metadata: string | null; + platform: string | null; + dimensions: string | null; + filePath: string; + type: string; +}) { + const meta = asset.metadata ? JSON.parse(asset.metadata) : {}; + return { + hook: meta.hook || meta.headline || meta.hookText || null, + caption: meta.caption || null, + cta: meta.cta || meta.ctaText || null, + style: meta.style || null, + platform: asset.platform, + dimensions: asset.dimensions, + type: asset.type, + isGemini: asset.filePath.toLowerCase().includes("/gemini/"), + isPoster: asset.filePath.toLowerCase().includes("/posters/"), + }; +} + +/** + * Build a prompt for the ad-creative-designer to generate variations of a winning asset. + */ +function buildVariationPrompt( + dna: ReturnType, + sourceFilePath: string, + count: number, + outputDir: string, + appConfig?: AppConfig +): string { + const appName = appConfig?.name ?? "the app"; + const primaryColor = appConfig?.primaryColor ?? "#0079FF"; + const accentColor = appConfig?.accentColor ?? "#FF9400"; + const assetsDir = appConfig?.assetsDir ?? "assets"; + + return `You are the Ad Creative Designer agent generating VARIATIONS of a winning ad. + +## Source Asset (the winner to riff on) +- File: ${sourceFilePath} +- Platform: ${dna.platform || "instagram"} +- Dimensions: ${dna.dimensions || "1080x1920"} +- Hook: "${dna.hook || "Unknown"}" +- CTA: "${dna.cta || "Download Free"}" +- Style: ${dna.isGemini ? "Gemini AI-generated" : dna.isPoster ? "Canvas Design poster" : "Unknown"} + +## Your Task +Generate ${count} NEW variations that keep the same emotional pattern as the winning hook but explore different angles. + +Rules: +- Same platform and dimensions as the source +- Same visual style (${dna.isGemini ? "Gemini AI photo-realistic" : "Canvas Design poster art"}) +- Same CTA pattern +- DIFFERENT hook text — same emotional structure, different scenarios +- Brand colors: ${primaryColor} (primary), ${accentColor} (accent) +- App name: ${appName} +- App icon: ${assetsDir}/icon.png — must appear in every ad + +${dna.isGemini ? `Use mcp__nanobanana__gemini_generate_image for each variation. +Use the source ad as a reference_image so Gemini keeps visual consistency. +Reference image path: \${CWD}/${sourceFilePath}` : `Create each poster programmatically using Playwright (HTML → PNG). +Match the visual language of the source poster.`} + +Save all outputs to: ${outputDir}/ +Name files: variation_${dna.platform || "ig"}_${count > 1 ? "{n}" : "1"}_${dna.dimensions || "1080x1920"}.png +Save a manifest: ${outputDir}/variation_manifest.json with fields: fileName, hook, platform, dimensions, parentFile. + +## Also Generate Copy +For each variation, write a platform-tuned caption. +Save to: ${outputDir}/captions.json as an array of { hook, caption, platform, fileName }.`; +} + +/** + * Spawn variations of a winning asset. + * Runs ad-creative-designer agent with a focused variation prompt. + */ +export async function spawnVariations(request: VariationRequest) { + const asset = await prisma.asset.findUnique({ + where: { id: request.assetId }, + include: { campaign: true }, + }); + + if (!asset) throw new Error("Asset not found"); + if (!asset.campaign) throw new Error("Asset has no campaign"); + + const dna = extractAssetDNA(asset); + const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const outputDir = `outputs/variations_${asset.id.slice(0, 8)}_${dateStr}`; + + mkdirSync(path.join(PIPELINE_ROOT, outputDir), { recursive: true }); + + const prompt = buildVariationPrompt( + dna, + asset.filePath, + request.count, + outputDir, + request.appConfig + ); + + const env = await loadPipelineEnv(); + + // Run the ad-creative agent with the variation prompt + const { output } = await runAgentStep( + "ad-creative-designer", + prompt, + PIPELINE_ROOT, + env + ); + + // Scan the output directory for generated assets + const scanResult = await scanOutputDirectory( + asset.campaignId, + outputDir, + PIPELINE_ROOT + ); + + // Link new assets to the parent + const newAssets = await prisma.asset.findMany({ + where: { + campaignId: asset.campaignId, + filePath: { startsWith: outputDir }, + parentAssetId: null, + }, + }); + + for (const newAsset of newAssets) { + await prisma.asset.update({ + where: { id: newAsset.id }, + data: { parentAssetId: asset.id }, + }); + } + + return { + output: output.slice(0, 500), + outputDir, + scanned: scanResult.scanned, + created: scanResult.created, + parentAssetId: asset.id, + }; +} +``` + +**Step 2: Commit** + +```bash +git add lib/variations.ts +git commit -m "feat: add variation spawner with mini-pipeline and asset DNA extraction" +``` + +--- + +### Task 4: API routes — `/api/assets/[id]/repurpose` and `/api/assets/[id]/variations` + +**Files:** +- Create: `app/api/assets/[id]/repurpose/route.ts` +- Create: `app/api/assets/[id]/variations/route.ts` + +**Step 1: Create repurpose route** + +```typescript +// app/api/assets/[id]/repurpose/route.ts +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { repurposeImage, retoneCaption, getAvailableFormats } from "@/lib/repurpose"; + +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 } }); + if (!asset) return Response.json({ error: "Not found" }, { status: 404 }); + + // Return available repurpose formats for this asset + const formats = getAvailableFormats(asset.dimensions); + return Response.json({ formats, currentDimensions: asset.dimensions }); +} + +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 { formats: targetFormats } = body as { formats: string[] }; + + const asset = await prisma.asset.findUnique({ where: { id } }); + if (!asset) return Response.json({ error: "Not found" }, { status: 404 }); + + if (asset.type !== "image") { + return Response.json({ error: "Only image assets can be repurposed" }, { status: 400 }); + } + + const outputDir = `outputs/repurposed_${id.slice(0, 8)}`; + const results = []; + + for (const fmt of targetFormats) { + const resized = await repurposeImage(asset.filePath, asset.dimensions, outputDir); + + for (const file of resized) { + // Re-tone caption if metadata has one + 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 if re-toning fails + } + } + + 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); + } + + break; // repurposeImage already does all formats + } + + return Response.json({ created: results.length, assets: results }); +} +``` + +**Step 2: Create variations route** + +```typescript +// app/api/assets/[id]/variations/route.ts +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { spawnVariations } from "@/lib/variations"; +import type { AppConfig } from "@/lib/claude"; + +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; + + // Return existing variations for this asset + const variations = await prisma.asset.findMany({ + where: { parentAssetId: id }, + orderBy: { createdAt: "desc" }, + }); + + return Response.json(variations); +} + +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 count = body.count || 5; + + const asset = await prisma.asset.findUnique({ + where: { id }, + include: { campaign: { include: { app: true } } }, + }); + + if (!asset) return Response.json({ error: "Not found" }, { status: 404 }); + if (asset.type !== "image") { + return Response.json({ error: "Only image assets can spawn variations" }, { status: 400 }); + } + + // Build AppConfig if campaign has an app + let appConfig: AppConfig | undefined; + if (asset.campaign?.app) { + const app = asset.campaign.app; + appConfig = { + name: app.name, + slug: app.slug, + primaryColor: app.primaryColor, + accentColor: app.accentColor, + darkBg: app.darkBg, + assetsDir: `apps/${app.slug}`, + brandIdentity: app.brandIdentity, + productInfo: app.productInfo, + platformGuidelines: app.platformGuidelines, + }; + } + + // Launch async — don't block the request + spawnVariations({ assetId: id, count, appConfig }).catch((err) => + console.error(`Variation spawning failed for asset ${id}:`, err) + ); + + return Response.json({ status: "spawning", count }); +} +``` + +**Step 3: Create directories and commit** + +```bash +mkdir -p app/api/assets/\[id\]/repurpose app/api/assets/\[id\]/variations +git add app/api/assets/\[id\]/repurpose/route.ts app/api/assets/\[id\]/variations/route.ts +git commit -m "feat: add repurpose and variations API routes" +``` + +--- + +### Task 5: UI — Add action buttons to asset card + +**Files:** +- Modify: `components/asset-card.tsx` +- Create: `components/repurpose-modal.tsx` +- Create: `components/variation-modal.tsx` + +**Step 1: Create repurpose modal** + +A dialog that shows available platform formats with checkboxes, then triggers the repurpose API. + +```typescript +// components/repurpose-modal.tsx +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface RepurposeModalProps { + assetId: string; + open: boolean; + onClose: () => void; +} + +interface FormatOption { + key: string; + label: string; + dimensions: string; +} + +const FORMAT_LABELS: Record = { + "instagram-feed": { key: "instagram-feed", label: "Instagram Feed", dimensions: "1080x1080" }, + "instagram-stories": { key: "instagram-stories", label: "Instagram Stories", dimensions: "1080x1920" }, + "tiktok": { key: "tiktok", label: "TikTok", dimensions: "1080x1920" }, + "nextdoor-spotlight": { key: "nextdoor-spotlight", label: "Nextdoor Spotlight", dimensions: "1200x1200" }, + "nextdoor-display": { key: "nextdoor-display", label: "Nextdoor Display", dimensions: "1200x628" }, +}; + +export function RepurposeModal({ assetId, open, onClose }: RepurposeModalProps) { + const [available, setAvailable] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState<{ created: number } | null>(null); + + useEffect(() => { + if (!open) return; + fetch(`/api/assets/${assetId}/repurpose`) + .then((r) => r.json()) + .then((data) => { + setAvailable(data.formats || []); + setSelected(new Set(data.formats || [])); + }) + .catch(() => {}); + }, [assetId, open]); + + async function handleRepurpose() { + setLoading(true); + const res = await fetch(`/api/assets/${assetId}/repurpose`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ formats: Array.from(selected) }), + }); + const data = await res.json(); + setResult(data); + setLoading(false); + } + + function toggle(key: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + return ( + !o && onClose()}> + + + Repurpose Asset + + Resize this image for other platforms. Captions will be re-toned to match each platform. + + + + {result ? ( +
+

{result.created} assets created

+

Check the Asset Library to review them.

+ +
+ ) : ( +
+
+ {available.map((key) => { + const fmt = FORMAT_LABELS[key]; + if (!fmt) return null; + return ( + + ); + })} +
+ +
+ )} +
+
+ ); +} +``` + +**Step 2: Create variation modal** + +A simple dialog with a count slider, then fires and forgets the variation API. + +```typescript +// components/variation-modal.tsx +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface VariationModalProps { + assetId: string; + assetName: string; + open: boolean; + onClose: () => void; +} + +export function VariationModal({ assetId, assetName, open, onClose }: VariationModalProps) { + const [count, setCount] = useState(5); + const [loading, setLoading] = useState(false); + const [launched, setLaunched] = useState(false); + + async function handleSpawn() { + setLoading(true); + await fetch(`/api/assets/${assetId}/variations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ count }), + }); + setLoading(false); + setLaunched(true); + } + + function handleClose() { + setLaunched(false); + setCount(5); + onClose(); + } + + return ( + !o && handleClose()}> + + + Spawn Variations + + Generate new ads with the same emotional pattern as “{assetName}” but with different hook angles. + + + + {launched ? ( +
+

Spawning {count} variations

+

+ The AI pipeline is running. New assets will appear in the Asset Library when ready. +

+ +
+ ) : ( +
+
+ + setCount(Math.max(1, Math.min(20, parseInt(e.target.value) || 5)))} + /> +

+ Each variation keeps the same visual style and CTA but explores a different hook angle. + Uses the original ad as a reference image for visual consistency. +

+
+ +
+ )} +
+
+ ); +} +``` + +**Step 3: Add buttons to asset card** + +Modify `components/asset-card.tsx` to add Repurpose and Variations buttons for image assets, and import/render the modals. + +Add these imports at the top: +```typescript +import { useState } from "react"; +import { Copy, Sparkles } from "lucide-react"; +import { RepurposeModal } from "./repurpose-modal"; +import { VariationModal } from "./variation-modal"; +``` + +Add state inside the component: +```typescript +const [repurposeOpen, setRepurposeOpen] = useState(false); +const [variationOpen, setVariationOpen] = useState(false); +``` + +Add a new row of buttons after the approve/reject row (inside the `(isImage || isVideo)` branch), and add the modals at the end of the component: + +After the existing approve/reject buttons div, add: +```tsx +{isImage && ( +
+ + +
+)} +``` + +Before the closing `` of the component, add the modals: +```tsx + setRepurposeOpen(false)} +/> + setVariationOpen(false)} +/> +``` + +**Step 4: Commit** + +```bash +git add components/repurpose-modal.tsx components/variation-modal.tsx components/asset-card.tsx +git commit -m "feat: add repurpose and variation buttons to asset cards with modals" +``` + +--- + +### Task 6: Show variation lineage in asset card + +**Files:** +- Modify: `components/asset-card.tsx` +- Modify: `app/api/assets/route.ts` (include parentAsset in query) + +**Step 1: Update assets API to include parent info** + +In `app/api/assets/route.ts`, update the `include` to also fetch parent asset name: + +```typescript +include: { + campaign: { select: { name: true } }, + parentAsset: { select: { id: true, fileName: true } }, +}, +``` + +**Step 2: Show lineage badge on asset card** + +Add `parentAsset` to the Asset interface in `asset-card.tsx`: +```typescript +parentAsset?: { id: string; fileName: string } | null; +``` + +Then in the info section, after the badges row, add: +```tsx +{asset.parentAsset && ( +

+ Derived from: {asset.parentAsset.fileName} +

+)} +``` + +**Step 3: Commit** + +```bash +git add app/api/assets/route.ts components/asset-card.tsx +git commit -m "feat: show parent asset lineage on derived assets" +``` + +--- + +### Task 7: Build and verify + +**Step 1: Run the build** + +```bash +npx next build +``` + +Expected: Build succeeds with no type errors. + +**Step 2: Verify routes exist in build output** + +Check for: +- `/api/assets/[id]/repurpose` — GET returns available formats, POST creates resized assets +- `/api/assets/[id]/variations` — GET returns existing variations, POST spawns new ones + +**Step 3: Final commit** + +```bash +git add -A +git commit -m "feat: complete variation spawner and content repurposing" +``` diff --git a/lib/repurpose.ts b/lib/repurpose.ts new file mode 100644 index 0000000..61a5ba7 --- /dev/null +++ b/lib/repurpose.ts @@ -0,0 +1,95 @@ +import sharp from "sharp"; +import path from "path"; +import { mkdirSync } from "fs"; +import { runAgentStep } from "./claude"; + +const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline"); + +const PLATFORM_FORMATS: Record = { + "instagram-feed": { width: 1080, height: 1080, label: "Instagram Feed" }, + "instagram-stories": { width: 1080, height: 1920, label: "Instagram Stories" }, + tiktok: { width: 1080, height: 1920, label: "TikTok" }, + "nextdoor-spotlight": { width: 1200, height: 1200, label: "Nextdoor Spotlight" }, + "nextdoor-display": { width: 1200, height: 628, label: "Nextdoor Display" }, +}; + +export function getAvailableFormats(currentDimensions: string | null): string[] { + return Object.keys(PLATFORM_FORMATS).filter((key) => { + const fmt = PLATFORM_FORMATS[key]; + return `${fmt.width}x${fmt.height}` !== currentDimensions; + }); +} + +export function getPlatformFormat(key: string) { + return PLATFORM_FORMATS[key] || null; +} + +/** + * Resize an image to target platform dimensions using Sharp. + * Uses cover fit + center crop for aspect ratio changes. + */ +export async function resizeImage( + sourcePath: string, + targetFormat: string, + outputDir: string +): Promise<{ filePath: string; fileName: string; dimensions: string; platform: string }> { + const fmt = PLATFORM_FORMATS[targetFormat]; + if (!fmt) throw new Error(`Unknown format: ${targetFormat}`); + + mkdirSync(path.join(PIPELINE_ROOT, outputDir), { recursive: true }); + + const sourceBase = path.basename(sourcePath, path.extname(sourcePath)); + const fileName = `${sourceBase}_${targetFormat}_${fmt.width}x${fmt.height}.png`; + const outputPath = path.join(outputDir, fileName); + const fullOutputPath = path.join(PIPELINE_ROOT, outputPath); + const fullSourcePath = path.join(PIPELINE_ROOT, sourcePath); + + await sharp(fullSourcePath) + .resize(fmt.width, fmt.height, { fit: "cover", position: "centre" }) + .png() + .toFile(fullOutputPath); + + const platform = targetFormat.split("-")[0]; + + return { filePath: outputPath, fileName, dimensions: `${fmt.width}x${fmt.height}`, platform }; +} + +/** + * Re-tone a caption for a different platform using Claude CLI. + */ +export async function retoneCaption( + originalCaption: string, + originalPlatform: string, + targetPlatform: string +): Promise { + const prompt = `You are a social media copywriter. Adapt this ${originalPlatform} caption for ${targetPlatform}. + +Original caption: +${originalCaption} + +Rules: +- ${targetPlatform === "tiktok" ? "Raw, authentic tone. Short. Trending hashtags. Hook in first line." : ""} +- ${targetPlatform === "instagram" ? "Polished, aspirational. Hook → Value → CTA → Hashtags. 150-300 chars." : ""} +- ${targetPlatform === "nextdoor" ? "Warm, neighborly. No hashtags. Reference local/community context." : ""} +- Keep the core message but match the platform's voice. +- Return ONLY the new caption text, nothing else.`; + + const { output } = await runAgentStep("caption-retone", prompt, PIPELINE_ROOT, {}); + return output.trim(); +} + +/** + * Repurpose an image asset to specific platform formats. + */ +export async function repurposeImage( + sourcePath: string, + targetFormats: string[], + outputDir: string +): Promise> { + const results = []; + for (const fmt of targetFormats) { + const result = await resizeImage(sourcePath, fmt, outputDir); + results.push(result); + } + return results; +} diff --git a/lib/variations.ts b/lib/variations.ts new file mode 100644 index 0000000..86becde --- /dev/null +++ b/lib/variations.ts @@ -0,0 +1,157 @@ +import { mkdirSync } from "fs"; +import path from "path"; +import { prisma } from "./prisma"; +import { runAgentStep } from "./claude"; +import { getAllSettings } from "./settings"; +import { scanOutputDirectory } from "./scanner"; +import type { AppConfig } from "./claude"; + +const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline"); + +interface VariationRequest { + assetId: string; + count: number; + appConfig?: AppConfig; +} + +async function loadPipelineEnv(): Promise> { + const settings = await getAllSettings(); + const env: Record = {}; + if (settings.TAVILY_API_KEY) env.TAVILY_API_KEY = settings.TAVILY_API_KEY; + if (settings.GEMINI_API_KEY) env.GEMINI_API_KEY = settings.GEMINI_API_KEY; + return env; +} + +/** + * Extract the "DNA" from an asset — hook, CTA, visual style, platform, dimensions. + */ +function extractAssetDNA(asset: { + metadata: string | null; + platform: string | null; + dimensions: string | null; + filePath: string; + type: string; +}) { + const meta = asset.metadata ? JSON.parse(asset.metadata) : {}; + return { + hook: meta.hook || meta.headline || meta.hookText || null, + caption: meta.caption || null, + cta: meta.cta || meta.ctaText || null, + style: meta.style || null, + platform: asset.platform, + dimensions: asset.dimensions, + type: asset.type, + isGemini: asset.filePath.toLowerCase().includes("/gemini/"), + isPoster: asset.filePath.toLowerCase().includes("/posters/"), + }; +} + +/** + * Build a prompt for the ad-creative-designer to generate variations of a winning asset. + */ +function buildVariationPrompt( + dna: ReturnType, + sourceFilePath: string, + count: number, + outputDir: string, + appConfig?: AppConfig +): string { + const appName = appConfig?.name ?? "the app"; + const primaryColor = appConfig?.primaryColor ?? "#0079FF"; + const accentColor = appConfig?.accentColor ?? "#FF9400"; + const assetsDir = appConfig?.assetsDir ?? "assets"; + + return `You are the Ad Creative Designer agent generating VARIATIONS of a winning ad. + +## Source Asset (the winner to riff on) +- File: ${sourceFilePath} +- Platform: ${dna.platform || "instagram"} +- Dimensions: ${dna.dimensions || "1080x1920"} +- Hook: "${dna.hook || "Unknown"}" +- CTA: "${dna.cta || "Download Free"}" +- Style: ${dna.isGemini ? "Gemini AI-generated" : dna.isPoster ? "Canvas Design poster" : "Unknown"} + +## Your Task +Generate ${count} NEW variations that keep the same emotional pattern as the winning hook but explore different angles. + +Rules: +- Same platform and dimensions as the source +- Same visual style (${dna.isGemini ? "Gemini AI photo-realistic" : "Canvas Design poster art"}) +- Same CTA pattern +- DIFFERENT hook text — same emotional structure, different scenarios +- Brand colors: ${primaryColor} (primary), ${accentColor} (accent) +- App name: ${appName} +- App icon: ${assetsDir}/icon.png — must appear in every ad + +${dna.isGemini ? `Use mcp__nanobanana__gemini_generate_image for each variation. +Use the source ad as a reference_image so Gemini keeps visual consistency. +Reference image path: \${CWD}/${sourceFilePath}` : `Create each poster programmatically using Playwright (HTML → PNG). +Match the visual language of the source poster.`} + +Save all outputs to: ${outputDir}/ +Name files: variation_${dna.platform || "ig"}_${count > 1 ? "{n}" : "1"}_${dna.dimensions || "1080x1920"}.png +Save a manifest: ${outputDir}/variation_manifest.json with fields: fileName, hook, platform, dimensions, parentFile. + +## Also Generate Copy +For each variation, write a platform-tuned caption. +Save to: ${outputDir}/captions.json as an array of { hook, caption, platform, fileName }.`; +} + +/** + * Spawn variations of a winning asset. + * Runs ad-creative-designer agent with a focused variation prompt. + */ +export async function spawnVariations(request: VariationRequest) { + const asset = await prisma.asset.findUnique({ + where: { id: request.assetId }, + include: { campaign: true }, + }); + + if (!asset) throw new Error("Asset not found"); + if (!asset.campaign) throw new Error("Asset has no campaign"); + + const dna = extractAssetDNA(asset); + const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const outputDir = `outputs/variations_${asset.id.slice(0, 8)}_${dateStr}`; + + mkdirSync(path.join(PIPELINE_ROOT, outputDir), { recursive: true }); + + const prompt = buildVariationPrompt( + dna, + asset.filePath, + request.count, + outputDir, + request.appConfig + ); + + const env = await loadPipelineEnv(); + + const { output } = await runAgentStep("ad-creative-designer", prompt, PIPELINE_ROOT, env); + + // Scan for generated assets + const scanResult = await scanOutputDirectory(asset.campaignId, outputDir, PIPELINE_ROOT); + + // Link new assets to the parent + const newAssets = await prisma.asset.findMany({ + where: { + campaignId: asset.campaignId, + filePath: { startsWith: outputDir }, + parentAssetId: null, + }, + }); + + for (const newAsset of newAssets) { + await prisma.asset.update({ + where: { id: newAsset.id }, + data: { parentAssetId: asset.id }, + }); + } + + return { + output: output.slice(0, 500), + outputDir, + scanned: scanResult.scanned, + created: scanResult.created, + parentAssetId: asset.id, + }; +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a0521da..3510092 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -83,6 +83,9 @@ model Asset { publishedTo String? // JSON array of platforms published to postizPostId String? postizMediaId String? + parentAssetId String? + parentAsset Asset? @relation("AssetVariations", fields: [parentAssetId], references: [id]) + variations Asset[] @relation("AssetVariations") createdAt DateTime @default(now()) }