# 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" ```