feat: add variation spawner and content repurposing

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) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-23 22:46:42 -05:00
parent 80a1ffbe4d
commit 2ab8af64d4
11 changed files with 1597 additions and 23 deletions
+67
View File
@@ -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 });
}