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:
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user