From 2ab8af64d49e4e70b2754c5cee348ccbcb302dd6 Mon Sep 17 00:00:00 2001
From: Trey t
Date: Mon, 23 Mar 2026 22:46:42 -0500
Subject: [PATCH] feat: add variation spawner and content repurposing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Variation spawner: select an image asset → spawn N variations that keep the
same emotional pattern/visual style but explore different hook angles. Runs
a focused ad-creative-designer agent with the original as a Gemini reference
image. New assets link back via parentAssetId.
Content repurposing: resize an image to all other platform dimensions using
Sharp (cover crop). Captions are re-toned for the target platform via Claude
CLI. No external APIs needed — fully local.
- Add parentAssetId self-relation to Asset model
- lib/repurpose.ts: Sharp resize, platform format mapping, caption re-toning
- lib/variations.ts: asset DNA extraction, variation prompt builder, mini-pipeline
- API routes: /api/assets/[id]/repurpose (GET formats, POST resize)
- API routes: /api/assets/[id]/variations (GET existing, POST spawn)
- Repurpose modal: checkbox list of target formats
- Variation modal: count picker, async launch
- Asset card: Repurpose + Variations buttons on image assets
- Asset lineage: "Derived from" shown on child assets
Co-Authored-By: Claude Opus 4.6 (1M context)
---
app/api/assets/[id]/repurpose/route.ts | 79 ++
app/api/assets/[id]/variations/route.ts | 67 ++
app/api/assets/route.ts | 5 +-
components/asset-card.tsx | 96 +-
components/asset-gallery.tsx | 1 +
components/repurpose-modal.tsx | 113 +++
components/variation-modal.tsx | 92 ++
...03-23-variation-spawner-and-repurposing.md | 912 ++++++++++++++++++
lib/repurpose.ts | 95 ++
lib/variations.ts | 157 +++
prisma/schema.prisma | 3 +
11 files changed, 1597 insertions(+), 23 deletions(-)
create mode 100644 app/api/assets/[id]/repurpose/route.ts
create mode 100644 app/api/assets/[id]/variations/route.ts
create mode 100644 components/repurpose-modal.tsx
create mode 100644 components/variation-modal.tsx
create mode 100644 docs/plans/2026-03-23-variation-spawner-and-repurposing.md
create mode 100644 lib/repurpose.ts
create mode 100644 lib/variations.ts
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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
+```
+
+**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 (
+
+ );
+}
+```
+
+**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())
}