Commit Β·
fb69858
1
Parent(s): 7aad68c
Add creative modifier endpoints and frontend components
Browse files- Introduced new API endpoints for uploading, analyzing, and modifying creative images, enhancing the creative workflow.
- Implemented frontend components for creative uploading, analysis display, and modification forms, allowing users to interactively modify creatives.
- Added type definitions for creative analysis and modification responses to ensure type safety across the application.
- Updated the header to include navigation to the new creative modification feature, improving user accessibility.
- frontend/app/creative/modify/page.tsx +501 -0
- frontend/components/creative/CreativeAnalysis.tsx +123 -0
- frontend/components/creative/CreativeUploader.tsx +262 -0
- frontend/components/creative/ModificationForm.tsx +391 -0
- frontend/components/creative/index.ts +3 -0
- frontend/components/layout/Header.tsx +2 -1
- frontend/lib/api/endpoints.ts +70 -0
- frontend/types/api.ts +50 -0
- main.py +326 -0
- services/creative_modifier.py +482 -0
frontend/app/creative/modify/page.tsx
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useCallback } from "react";
|
| 4 |
+
import { CreativeUploader } from "@/components/creative/CreativeUploader";
|
| 5 |
+
import { CreativeAnalysis } from "@/components/creative/CreativeAnalysis";
|
| 6 |
+
import { ModificationForm } from "@/components/creative/ModificationForm";
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 8 |
+
import {
|
| 9 |
+
uploadCreative,
|
| 10 |
+
analyzeCreativeByUrl,
|
| 11 |
+
analyzeCreativeByFile,
|
| 12 |
+
modifyCreative,
|
| 13 |
+
} from "@/lib/api/endpoints";
|
| 14 |
+
import type {
|
| 15 |
+
CreativeAnalysisData,
|
| 16 |
+
ModificationMode,
|
| 17 |
+
ModifiedImageResult,
|
| 18 |
+
} from "@/types/api";
|
| 19 |
+
|
| 20 |
+
type WorkflowStep = "upload" | "analysis" | "modify" | "result";
|
| 21 |
+
|
| 22 |
+
export default function CreativeModifyPage() {
|
| 23 |
+
// Workflow state
|
| 24 |
+
const [currentStep, setCurrentStep] = useState<WorkflowStep>("upload");
|
| 25 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 26 |
+
const [error, setError] = useState<string | null>(null);
|
| 27 |
+
|
| 28 |
+
// Image state
|
| 29 |
+
const [originalImageUrl, setOriginalImageUrl] = useState<string | null>(null);
|
| 30 |
+
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
| 31 |
+
|
| 32 |
+
// Analysis state
|
| 33 |
+
const [analysis, setAnalysis] = useState<CreativeAnalysisData | null>(null);
|
| 34 |
+
|
| 35 |
+
// Modification form state
|
| 36 |
+
const [angle, setAngle] = useState("");
|
| 37 |
+
const [concept, setConcept] = useState("");
|
| 38 |
+
const [mode, setMode] = useState<ModificationMode>("modify");
|
| 39 |
+
const [imageModel, setImageModel] = useState<string | null>(null);
|
| 40 |
+
|
| 41 |
+
// Result state
|
| 42 |
+
const [result, setResult] = useState<ModifiedImageResult | null>(null);
|
| 43 |
+
const [generatedPrompt, setGeneratedPrompt] = useState<string | null>(null);
|
| 44 |
+
|
| 45 |
+
// Handle image ready from uploader
|
| 46 |
+
const handleImageReady = useCallback(async (imageUrl: string, file?: File) => {
|
| 47 |
+
setIsLoading(true);
|
| 48 |
+
setError(null);
|
| 49 |
+
setOriginalImageUrl(imageUrl);
|
| 50 |
+
setUploadedFile(file || null);
|
| 51 |
+
|
| 52 |
+
try {
|
| 53 |
+
let analysisResponse;
|
| 54 |
+
|
| 55 |
+
if (file) {
|
| 56 |
+
// If we have a file, first upload it to get a URL, then analyze
|
| 57 |
+
const uploadResponse = await uploadCreative(file);
|
| 58 |
+
if (uploadResponse.status !== "success" || !uploadResponse.image_url) {
|
| 59 |
+
throw new Error(uploadResponse.error || "Failed to upload image");
|
| 60 |
+
}
|
| 61 |
+
setOriginalImageUrl(uploadResponse.image_url);
|
| 62 |
+
|
| 63 |
+
// Now analyze using the uploaded URL
|
| 64 |
+
analysisResponse = await analyzeCreativeByUrl({
|
| 65 |
+
image_url: uploadResponse.image_url,
|
| 66 |
+
});
|
| 67 |
+
} else {
|
| 68 |
+
// Analyze by URL directly
|
| 69 |
+
analysisResponse = await analyzeCreativeByUrl({ image_url: imageUrl });
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
if (analysisResponse.status !== "success" || !analysisResponse.analysis) {
|
| 73 |
+
throw new Error(analysisResponse.error || "Failed to analyze image");
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
setAnalysis(analysisResponse.analysis);
|
| 77 |
+
setCurrentStep("analysis");
|
| 78 |
+
} catch (err) {
|
| 79 |
+
setError(err instanceof Error ? err.message : "An error occurred");
|
| 80 |
+
} finally {
|
| 81 |
+
setIsLoading(false);
|
| 82 |
+
}
|
| 83 |
+
}, []);
|
| 84 |
+
|
| 85 |
+
// Handle modification submission
|
| 86 |
+
const handleModify = useCallback(async () => {
|
| 87 |
+
if (!originalImageUrl) return;
|
| 88 |
+
|
| 89 |
+
setIsLoading(true);
|
| 90 |
+
setError(null);
|
| 91 |
+
|
| 92 |
+
try {
|
| 93 |
+
const response = await modifyCreative({
|
| 94 |
+
image_url: originalImageUrl,
|
| 95 |
+
analysis: analysis || undefined,
|
| 96 |
+
angle: angle.trim() || undefined,
|
| 97 |
+
concept: concept.trim() || undefined,
|
| 98 |
+
mode,
|
| 99 |
+
image_model: imageModel,
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
if (response.status !== "success" || !response.image) {
|
| 103 |
+
throw new Error(response.error || "Failed to modify creative");
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
setResult(response.image);
|
| 107 |
+
setGeneratedPrompt(response.prompt || null);
|
| 108 |
+
setCurrentStep("result");
|
| 109 |
+
} catch (err) {
|
| 110 |
+
setError(err instanceof Error ? err.message : "An error occurred");
|
| 111 |
+
} finally {
|
| 112 |
+
setIsLoading(false);
|
| 113 |
+
}
|
| 114 |
+
}, [originalImageUrl, analysis, angle, concept, mode, imageModel]);
|
| 115 |
+
|
| 116 |
+
// Reset to start over
|
| 117 |
+
const handleStartOver = useCallback(() => {
|
| 118 |
+
setCurrentStep("upload");
|
| 119 |
+
setOriginalImageUrl(null);
|
| 120 |
+
setUploadedFile(null);
|
| 121 |
+
setAnalysis(null);
|
| 122 |
+
setAngle("");
|
| 123 |
+
setConcept("");
|
| 124 |
+
setMode("modify");
|
| 125 |
+
setImageModel(null);
|
| 126 |
+
setResult(null);
|
| 127 |
+
setGeneratedPrompt(null);
|
| 128 |
+
setError(null);
|
| 129 |
+
}, []);
|
| 130 |
+
|
| 131 |
+
// Go back to modification form
|
| 132 |
+
const handleModifyAgain = useCallback(() => {
|
| 133 |
+
setResult(null);
|
| 134 |
+
setGeneratedPrompt(null);
|
| 135 |
+
setCurrentStep("analysis");
|
| 136 |
+
}, []);
|
| 137 |
+
|
| 138 |
+
// Go back to upload step
|
| 139 |
+
const handleBackToUpload = useCallback(() => {
|
| 140 |
+
setCurrentStep("upload");
|
| 141 |
+
// Keep the original image URL for preview but allow re-upload
|
| 142 |
+
setError(null);
|
| 143 |
+
}, []);
|
| 144 |
+
|
| 145 |
+
// Go back to analysis step from result
|
| 146 |
+
const handleBackToAnalysis = useCallback(() => {
|
| 147 |
+
setResult(null);
|
| 148 |
+
setGeneratedPrompt(null);
|
| 149 |
+
setCurrentStep("analysis");
|
| 150 |
+
setError(null);
|
| 151 |
+
}, []);
|
| 152 |
+
|
| 153 |
+
// Get step index for navigation
|
| 154 |
+
const getStepIndex = (step: WorkflowStep): number => {
|
| 155 |
+
const steps: WorkflowStep[] = ["upload", "analysis", "result"];
|
| 156 |
+
return steps.indexOf(step);
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
// Check if a step is accessible (completed or current)
|
| 160 |
+
const isStepAccessible = (stepKey: string): boolean => {
|
| 161 |
+
const stepOrder: WorkflowStep[] = ["upload", "analysis", "result"];
|
| 162 |
+
const currentIndex = stepOrder.indexOf(currentStep);
|
| 163 |
+
const targetIndex = stepOrder.indexOf(stepKey as WorkflowStep);
|
| 164 |
+
return targetIndex <= currentIndex;
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
// Handle step click for navigation
|
| 168 |
+
const handleStepClick = useCallback((stepKey: string) => {
|
| 169 |
+
if (!isStepAccessible(stepKey)) return;
|
| 170 |
+
|
| 171 |
+
if (stepKey === "upload") {
|
| 172 |
+
handleBackToUpload();
|
| 173 |
+
} else if (stepKey === "analysis" && currentStep === "result") {
|
| 174 |
+
handleBackToAnalysis();
|
| 175 |
+
}
|
| 176 |
+
}, [currentStep, handleBackToUpload, handleBackToAnalysis]);
|
| 177 |
+
|
| 178 |
+
return (
|
| 179 |
+
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50">
|
| 180 |
+
<div className="container mx-auto px-4 py-8">
|
| 181 |
+
{/* Header */}
|
| 182 |
+
<div className="text-center mb-8">
|
| 183 |
+
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
| 184 |
+
Creative Modifier
|
| 185 |
+
</h1>
|
| 186 |
+
<p className="text-gray-600 max-w-2xl mx-auto">
|
| 187 |
+
Upload your existing creative, let AI analyze it, then apply new
|
| 188 |
+
angles or concepts to generate variations
|
| 189 |
+
</p>
|
| 190 |
+
</div>
|
| 191 |
+
|
| 192 |
+
{/* Progress Steps */}
|
| 193 |
+
<div className="flex justify-center mb-8">
|
| 194 |
+
<div className="flex items-center gap-2">
|
| 195 |
+
{[
|
| 196 |
+
{ key: "upload", label: "Upload" },
|
| 197 |
+
{ key: "analysis", label: "Analysis & Modify" },
|
| 198 |
+
{ key: "result", label: "Result" },
|
| 199 |
+
].map((step, index) => {
|
| 200 |
+
const isActive = currentStep === step.key ||
|
| 201 |
+
(step.key === "analysis" && currentStep === "analysis");
|
| 202 |
+
const isCompleted = getStepIndex(currentStep) > index;
|
| 203 |
+
const canClick = isStepAccessible(step.key) && step.key !== currentStep;
|
| 204 |
+
|
| 205 |
+
return (
|
| 206 |
+
<React.Fragment key={step.key}>
|
| 207 |
+
{index > 0 && (
|
| 208 |
+
<div
|
| 209 |
+
className={`w-12 h-0.5 ${
|
| 210 |
+
isCompleted || isActive ? "bg-blue-500" : "bg-gray-300"
|
| 211 |
+
}`}
|
| 212 |
+
/>
|
| 213 |
+
)}
|
| 214 |
+
<button
|
| 215 |
+
type="button"
|
| 216 |
+
onClick={() => handleStepClick(step.key)}
|
| 217 |
+
disabled={!canClick}
|
| 218 |
+
className={`flex flex-col items-center gap-1 transition-all ${
|
| 219 |
+
canClick ? "cursor-pointer hover:scale-105" : "cursor-default"
|
| 220 |
+
}`}
|
| 221 |
+
>
|
| 222 |
+
<div
|
| 223 |
+
className={`flex items-center justify-center w-10 h-10 rounded-full text-sm font-medium transition-all ${
|
| 224 |
+
isActive
|
| 225 |
+
? "bg-blue-500 text-white shadow-lg"
|
| 226 |
+
: isCompleted
|
| 227 |
+
? "bg-blue-100 text-blue-600 hover:bg-blue-200"
|
| 228 |
+
: "bg-gray-200 text-gray-500"
|
| 229 |
+
}`}
|
| 230 |
+
>
|
| 231 |
+
{isCompleted ? (
|
| 232 |
+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
| 233 |
+
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
| 234 |
+
</svg>
|
| 235 |
+
) : (
|
| 236 |
+
index + 1
|
| 237 |
+
)}
|
| 238 |
+
</div>
|
| 239 |
+
<span className={`text-xs font-medium ${
|
| 240 |
+
isActive ? "text-blue-600" : isCompleted ? "text-blue-500" : "text-gray-500"
|
| 241 |
+
}`}>
|
| 242 |
+
{step.label}
|
| 243 |
+
</span>
|
| 244 |
+
</button>
|
| 245 |
+
</React.Fragment>
|
| 246 |
+
);
|
| 247 |
+
})}
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
{/* Error Display */}
|
| 252 |
+
{error && (
|
| 253 |
+
<div className="max-w-2xl mx-auto mb-6">
|
| 254 |
+
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-red-600">
|
| 255 |
+
<div className="flex items-center gap-2">
|
| 256 |
+
<svg
|
| 257 |
+
className="w-5 h-5"
|
| 258 |
+
fill="currentColor"
|
| 259 |
+
viewBox="0 0 20 20"
|
| 260 |
+
>
|
| 261 |
+
<path
|
| 262 |
+
fillRule="evenodd"
|
| 263 |
+
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
| 264 |
+
clipRule="evenodd"
|
| 265 |
+
/>
|
| 266 |
+
</svg>
|
| 267 |
+
{error}
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
)}
|
| 272 |
+
|
| 273 |
+
{/* Step Content */}
|
| 274 |
+
<div className="max-w-4xl mx-auto">
|
| 275 |
+
{/* Upload Step */}
|
| 276 |
+
{currentStep === "upload" && (
|
| 277 |
+
<CreativeUploader
|
| 278 |
+
onImageReady={handleImageReady}
|
| 279 |
+
isLoading={isLoading}
|
| 280 |
+
/>
|
| 281 |
+
)}
|
| 282 |
+
|
| 283 |
+
{/* Analysis Step */}
|
| 284 |
+
{currentStep === "analysis" && analysis && (
|
| 285 |
+
<div className="space-y-6">
|
| 286 |
+
{/* Back Button */}
|
| 287 |
+
<button
|
| 288 |
+
type="button"
|
| 289 |
+
onClick={handleBackToUpload}
|
| 290 |
+
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
|
| 291 |
+
>
|
| 292 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 293 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
| 294 |
+
</svg>
|
| 295 |
+
<span className="font-medium">Back to Upload</span>
|
| 296 |
+
</button>
|
| 297 |
+
|
| 298 |
+
{/* Original Image Preview */}
|
| 299 |
+
{originalImageUrl && (
|
| 300 |
+
<Card variant="glass">
|
| 301 |
+
<CardHeader>
|
| 302 |
+
<CardTitle>Original Creative</CardTitle>
|
| 303 |
+
</CardHeader>
|
| 304 |
+
<CardContent>
|
| 305 |
+
<div className="rounded-xl overflow-hidden bg-gray-100">
|
| 306 |
+
<img
|
| 307 |
+
src={originalImageUrl}
|
| 308 |
+
alt="Original creative"
|
| 309 |
+
className="w-full h-auto max-h-64 object-contain"
|
| 310 |
+
/>
|
| 311 |
+
</div>
|
| 312 |
+
</CardContent>
|
| 313 |
+
</Card>
|
| 314 |
+
)}
|
| 315 |
+
|
| 316 |
+
{/* Analysis Display */}
|
| 317 |
+
<CreativeAnalysis analysis={analysis} />
|
| 318 |
+
|
| 319 |
+
{/* Modification Form */}
|
| 320 |
+
<ModificationForm
|
| 321 |
+
angle={angle}
|
| 322 |
+
concept={concept}
|
| 323 |
+
mode={mode}
|
| 324 |
+
imageModel={imageModel}
|
| 325 |
+
onAngleChange={setAngle}
|
| 326 |
+
onConceptChange={setConcept}
|
| 327 |
+
onModeChange={setMode}
|
| 328 |
+
onImageModelChange={setImageModel}
|
| 329 |
+
onSubmit={handleModify}
|
| 330 |
+
isLoading={isLoading}
|
| 331 |
+
/>
|
| 332 |
+
</div>
|
| 333 |
+
)}
|
| 334 |
+
|
| 335 |
+
{/* Result Step */}
|
| 336 |
+
{currentStep === "result" && result && (
|
| 337 |
+
<div className="space-y-6">
|
| 338 |
+
{/* Back Button */}
|
| 339 |
+
<button
|
| 340 |
+
type="button"
|
| 341 |
+
onClick={handleBackToAnalysis}
|
| 342 |
+
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
|
| 343 |
+
>
|
| 344 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 345 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
| 346 |
+
</svg>
|
| 347 |
+
<span className="font-medium">Back to Analysis</span>
|
| 348 |
+
</button>
|
| 349 |
+
|
| 350 |
+
{/* Before / After Comparison */}
|
| 351 |
+
<Card variant="glass">
|
| 352 |
+
<CardHeader>
|
| 353 |
+
<CardTitle>Result</CardTitle>
|
| 354 |
+
</CardHeader>
|
| 355 |
+
<CardContent>
|
| 356 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 357 |
+
{/* Original */}
|
| 358 |
+
<div>
|
| 359 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-2">
|
| 360 |
+
Original
|
| 361 |
+
</h4>
|
| 362 |
+
<div className="rounded-xl overflow-hidden bg-gray-100">
|
| 363 |
+
{originalImageUrl && (
|
| 364 |
+
<img
|
| 365 |
+
src={originalImageUrl}
|
| 366 |
+
alt="Original creative"
|
| 367 |
+
className="w-full h-auto object-contain"
|
| 368 |
+
/>
|
| 369 |
+
)}
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
|
| 373 |
+
{/* Modified */}
|
| 374 |
+
<div>
|
| 375 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-2">
|
| 376 |
+
{mode === "modify" ? "Modified" : "Inspired"}
|
| 377 |
+
</h4>
|
| 378 |
+
<div className="rounded-xl overflow-hidden bg-gray-100">
|
| 379 |
+
{result.image_url && (
|
| 380 |
+
<img
|
| 381 |
+
src={result.image_url}
|
| 382 |
+
alt="Modified creative"
|
| 383 |
+
className="w-full h-auto object-contain"
|
| 384 |
+
/>
|
| 385 |
+
)}
|
| 386 |
+
</div>
|
| 387 |
+
</div>
|
| 388 |
+
</div>
|
| 389 |
+
</CardContent>
|
| 390 |
+
</Card>
|
| 391 |
+
|
| 392 |
+
{/* Applied Changes */}
|
| 393 |
+
<Card variant="glass">
|
| 394 |
+
<CardHeader>
|
| 395 |
+
<CardTitle>Applied Changes</CardTitle>
|
| 396 |
+
</CardHeader>
|
| 397 |
+
<CardContent className="space-y-4">
|
| 398 |
+
<div className="grid grid-cols-2 gap-4">
|
| 399 |
+
{result.applied_angle && (
|
| 400 |
+
<div className="bg-orange-50 rounded-xl p-4">
|
| 401 |
+
<h4 className="text-sm font-semibold text-orange-700 mb-1">
|
| 402 |
+
Applied Angle
|
| 403 |
+
</h4>
|
| 404 |
+
<p className="text-orange-900">
|
| 405 |
+
{result.applied_angle}
|
| 406 |
+
</p>
|
| 407 |
+
</div>
|
| 408 |
+
)}
|
| 409 |
+
{result.applied_concept && (
|
| 410 |
+
<div className="bg-green-50 rounded-xl p-4">
|
| 411 |
+
<h4 className="text-sm font-semibold text-green-700 mb-1">
|
| 412 |
+
Applied Concept
|
| 413 |
+
</h4>
|
| 414 |
+
<p className="text-green-900">
|
| 415 |
+
{result.applied_concept}
|
| 416 |
+
</p>
|
| 417 |
+
</div>
|
| 418 |
+
)}
|
| 419 |
+
</div>
|
| 420 |
+
|
| 421 |
+
<div className="bg-gray-50 rounded-xl p-4">
|
| 422 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-1">
|
| 423 |
+
Mode
|
| 424 |
+
</h4>
|
| 425 |
+
<p className="text-gray-900 capitalize">{result.mode}</p>
|
| 426 |
+
</div>
|
| 427 |
+
|
| 428 |
+
{result.model_used && (
|
| 429 |
+
<div className="bg-blue-50 rounded-xl p-4">
|
| 430 |
+
<h4 className="text-sm font-semibold text-blue-700 mb-1">
|
| 431 |
+
Model Used
|
| 432 |
+
</h4>
|
| 433 |
+
<p className="text-blue-900">{result.model_used}</p>
|
| 434 |
+
</div>
|
| 435 |
+
)}
|
| 436 |
+
|
| 437 |
+
{generatedPrompt && (
|
| 438 |
+
<div>
|
| 439 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-1">
|
| 440 |
+
Generated Prompt
|
| 441 |
+
</h4>
|
| 442 |
+
<p className="text-gray-600 text-sm bg-gray-50 p-3 rounded-lg">
|
| 443 |
+
{generatedPrompt}
|
| 444 |
+
</p>
|
| 445 |
+
</div>
|
| 446 |
+
)}
|
| 447 |
+
</CardContent>
|
| 448 |
+
</Card>
|
| 449 |
+
|
| 450 |
+
{/* Action Buttons */}
|
| 451 |
+
<div className="flex gap-4">
|
| 452 |
+
<button
|
| 453 |
+
type="button"
|
| 454 |
+
onClick={handleModifyAgain}
|
| 455 |
+
className="flex-1 py-3 px-4 border-2 border-gray-300 text-gray-700 font-medium rounded-xl hover:bg-gray-50 transition-colors"
|
| 456 |
+
>
|
| 457 |
+
Modify Again
|
| 458 |
+
</button>
|
| 459 |
+
<button
|
| 460 |
+
type="button"
|
| 461 |
+
onClick={handleStartOver}
|
| 462 |
+
className="flex-1 py-3 px-4 bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-bold rounded-xl hover:from-blue-600 hover:to-cyan-600 transition-all"
|
| 463 |
+
>
|
| 464 |
+
Start Over
|
| 465 |
+
</button>
|
| 466 |
+
</div>
|
| 467 |
+
|
| 468 |
+
{/* Download Button */}
|
| 469 |
+
{result.image_url && (
|
| 470 |
+
<div className="text-center">
|
| 471 |
+
<a
|
| 472 |
+
href={result.image_url}
|
| 473 |
+
download={result.filename || "modified-creative.png"}
|
| 474 |
+
target="_blank"
|
| 475 |
+
rel="noopener noreferrer"
|
| 476 |
+
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
|
| 477 |
+
>
|
| 478 |
+
<svg
|
| 479 |
+
className="w-5 h-5"
|
| 480 |
+
fill="none"
|
| 481 |
+
stroke="currentColor"
|
| 482 |
+
viewBox="0 0 24 24"
|
| 483 |
+
>
|
| 484 |
+
<path
|
| 485 |
+
strokeLinecap="round"
|
| 486 |
+
strokeLinejoin="round"
|
| 487 |
+
strokeWidth={2}
|
| 488 |
+
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
| 489 |
+
/>
|
| 490 |
+
</svg>
|
| 491 |
+
Download Image
|
| 492 |
+
</a>
|
| 493 |
+
</div>
|
| 494 |
+
)}
|
| 495 |
+
</div>
|
| 496 |
+
)}
|
| 497 |
+
</div>
|
| 498 |
+
</div>
|
| 499 |
+
</div>
|
| 500 |
+
);
|
| 501 |
+
}
|
frontend/components/creative/CreativeAnalysis.tsx
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 5 |
+
import type { CreativeAnalysisData } from "@/types/api";
|
| 6 |
+
|
| 7 |
+
interface CreativeAnalysisProps {
|
| 8 |
+
analysis: CreativeAnalysisData;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export const CreativeAnalysis: React.FC<CreativeAnalysisProps> = ({
|
| 12 |
+
analysis,
|
| 13 |
+
}) => {
|
| 14 |
+
return (
|
| 15 |
+
<Card variant="glass">
|
| 16 |
+
<CardHeader>
|
| 17 |
+
<CardTitle>Creative Analysis</CardTitle>
|
| 18 |
+
</CardHeader>
|
| 19 |
+
<CardContent className="space-y-6">
|
| 20 |
+
{/* Visual Style & Mood */}
|
| 21 |
+
<div className="grid grid-cols-2 gap-4">
|
| 22 |
+
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-xl p-4">
|
| 23 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-1">Visual Style</h4>
|
| 24 |
+
<p className="text-gray-900 font-medium">{analysis.visual_style}</p>
|
| 25 |
+
</div>
|
| 26 |
+
<div className="bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl p-4">
|
| 27 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-1">Mood</h4>
|
| 28 |
+
<p className="text-gray-900 font-medium">{analysis.mood}</p>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
{/* Color Palette */}
|
| 33 |
+
<div>
|
| 34 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-2">Color Palette</h4>
|
| 35 |
+
<div className="flex flex-wrap gap-2">
|
| 36 |
+
{analysis.color_palette.map((color, index) => (
|
| 37 |
+
<span
|
| 38 |
+
key={index}
|
| 39 |
+
className="px-3 py-1 bg-white rounded-full text-sm text-gray-700 border border-gray-200"
|
| 40 |
+
>
|
| 41 |
+
{color}
|
| 42 |
+
</span>
|
| 43 |
+
))}
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
{/* Composition & Subject */}
|
| 48 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 49 |
+
<div>
|
| 50 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-1">Composition</h4>
|
| 51 |
+
<p className="text-gray-600 text-sm">{analysis.composition}</p>
|
| 52 |
+
</div>
|
| 53 |
+
<div>
|
| 54 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-1">Subject Matter</h4>
|
| 55 |
+
<p className="text-gray-600 text-sm">{analysis.subject_matter}</p>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
{/* Text Content (if any) */}
|
| 60 |
+
{analysis.text_content && (
|
| 61 |
+
<div>
|
| 62 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-1">Text Content</h4>
|
| 63 |
+
<p className="text-gray-600 text-sm bg-gray-50 p-3 rounded-lg">"{analysis.text_content}"</p>
|
| 64 |
+
</div>
|
| 65 |
+
)}
|
| 66 |
+
|
| 67 |
+
{/* Current Angle & Concept */}
|
| 68 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 69 |
+
{analysis.current_angle && (
|
| 70 |
+
<div className="bg-orange-50 rounded-xl p-4">
|
| 71 |
+
<h4 className="text-sm font-semibold text-orange-700 mb-1">Current Angle</h4>
|
| 72 |
+
<p className="text-orange-900 font-medium">{analysis.current_angle}</p>
|
| 73 |
+
</div>
|
| 74 |
+
)}
|
| 75 |
+
{analysis.current_concept && (
|
| 76 |
+
<div className="bg-green-50 rounded-xl p-4">
|
| 77 |
+
<h4 className="text-sm font-semibold text-green-700 mb-1">Current Concept</h4>
|
| 78 |
+
<p className="text-green-900 font-medium">{analysis.current_concept}</p>
|
| 79 |
+
</div>
|
| 80 |
+
)}
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
{/* Target Audience */}
|
| 84 |
+
{analysis.target_audience && (
|
| 85 |
+
<div>
|
| 86 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-1">Target Audience</h4>
|
| 87 |
+
<p className="text-gray-600 text-sm">{analysis.target_audience}</p>
|
| 88 |
+
</div>
|
| 89 |
+
)}
|
| 90 |
+
|
| 91 |
+
{/* Strengths */}
|
| 92 |
+
<div>
|
| 93 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-2">Strengths</h4>
|
| 94 |
+
<ul className="space-y-1">
|
| 95 |
+
{analysis.strengths.map((strength, index) => (
|
| 96 |
+
<li key={index} className="flex items-start gap-2 text-sm text-gray-600">
|
| 97 |
+
<svg className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
| 98 |
+
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
| 99 |
+
</svg>
|
| 100 |
+
{strength}
|
| 101 |
+
</li>
|
| 102 |
+
))}
|
| 103 |
+
</ul>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* Areas for Improvement */}
|
| 107 |
+
<div>
|
| 108 |
+
<h4 className="text-sm font-semibold text-gray-700 mb-2">Areas for Improvement</h4>
|
| 109 |
+
<ul className="space-y-1">
|
| 110 |
+
{analysis.areas_for_improvement.map((area, index) => (
|
| 111 |
+
<li key={index} className="flex items-start gap-2 text-sm text-gray-600">
|
| 112 |
+
<svg className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
| 113 |
+
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
| 114 |
+
</svg>
|
| 115 |
+
{area}
|
| 116 |
+
</li>
|
| 117 |
+
))}
|
| 118 |
+
</ul>
|
| 119 |
+
</div>
|
| 120 |
+
</CardContent>
|
| 121 |
+
</Card>
|
| 122 |
+
);
|
| 123 |
+
};
|
frontend/components/creative/CreativeUploader.tsx
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useCallback, useRef } from "react";
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card";
|
| 5 |
+
|
| 6 |
+
interface CreativeUploaderProps {
|
| 7 |
+
onImageReady: (imageUrl: string, imageBytes?: File) => void;
|
| 8 |
+
isLoading?: boolean;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export const CreativeUploader: React.FC<CreativeUploaderProps> = ({
|
| 12 |
+
onImageReady,
|
| 13 |
+
isLoading = false,
|
| 14 |
+
}) => {
|
| 15 |
+
const [uploadMode, setUploadMode] = useState<"file" | "url">("file");
|
| 16 |
+
const [urlInput, setUrlInput] = useState("");
|
| 17 |
+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
| 18 |
+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
| 19 |
+
const [isDragging, setIsDragging] = useState(false);
|
| 20 |
+
const [error, setError] = useState<string | null>(null);
|
| 21 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 22 |
+
|
| 23 |
+
const handleFileSelect = useCallback((file: File) => {
|
| 24 |
+
setError(null);
|
| 25 |
+
|
| 26 |
+
// Validate file type
|
| 27 |
+
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
|
| 28 |
+
if (!allowedTypes.includes(file.type)) {
|
| 29 |
+
setError("Invalid file type. Please upload PNG, JPG, or WebP images.");
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Validate file size (max 10MB)
|
| 34 |
+
if (file.size > 10 * 1024 * 1024) {
|
| 35 |
+
setError("File too large. Maximum size is 10MB.");
|
| 36 |
+
return;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
setSelectedFile(file);
|
| 40 |
+
const objectUrl = URL.createObjectURL(file);
|
| 41 |
+
setPreviewUrl(objectUrl);
|
| 42 |
+
}, []);
|
| 43 |
+
|
| 44 |
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
| 45 |
+
e.preventDefault();
|
| 46 |
+
setIsDragging(true);
|
| 47 |
+
}, []);
|
| 48 |
+
|
| 49 |
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
| 50 |
+
e.preventDefault();
|
| 51 |
+
setIsDragging(false);
|
| 52 |
+
}, []);
|
| 53 |
+
|
| 54 |
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
| 55 |
+
e.preventDefault();
|
| 56 |
+
setIsDragging(false);
|
| 57 |
+
|
| 58 |
+
const files = e.dataTransfer.files;
|
| 59 |
+
if (files.length > 0) {
|
| 60 |
+
handleFileSelect(files[0]);
|
| 61 |
+
}
|
| 62 |
+
}, [handleFileSelect]);
|
| 63 |
+
|
| 64 |
+
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
| 65 |
+
const files = e.target.files;
|
| 66 |
+
if (files && files.length > 0) {
|
| 67 |
+
handleFileSelect(files[0]);
|
| 68 |
+
}
|
| 69 |
+
}, [handleFileSelect]);
|
| 70 |
+
|
| 71 |
+
const handleUrlSubmit = useCallback(() => {
|
| 72 |
+
setError(null);
|
| 73 |
+
|
| 74 |
+
if (!urlInput.trim()) {
|
| 75 |
+
setError("Please enter an image URL.");
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// Basic URL validation
|
| 80 |
+
try {
|
| 81 |
+
new URL(urlInput);
|
| 82 |
+
} catch {
|
| 83 |
+
setError("Invalid URL format.");
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
setPreviewUrl(urlInput);
|
| 88 |
+
setSelectedFile(null);
|
| 89 |
+
}, [urlInput]);
|
| 90 |
+
|
| 91 |
+
const handleContinue = useCallback(() => {
|
| 92 |
+
if (uploadMode === "file" && selectedFile) {
|
| 93 |
+
// For file upload, we pass both the preview URL and the file
|
| 94 |
+
onImageReady(previewUrl!, selectedFile);
|
| 95 |
+
} else if (uploadMode === "url" && previewUrl) {
|
| 96 |
+
onImageReady(previewUrl);
|
| 97 |
+
}
|
| 98 |
+
}, [uploadMode, selectedFile, previewUrl, onImageReady]);
|
| 99 |
+
|
| 100 |
+
const handleClear = useCallback(() => {
|
| 101 |
+
setPreviewUrl(null);
|
| 102 |
+
setSelectedFile(null);
|
| 103 |
+
setUrlInput("");
|
| 104 |
+
setError(null);
|
| 105 |
+
if (fileInputRef.current) {
|
| 106 |
+
fileInputRef.current.value = "";
|
| 107 |
+
}
|
| 108 |
+
}, []);
|
| 109 |
+
|
| 110 |
+
return (
|
| 111 |
+
<Card variant="glass">
|
| 112 |
+
<CardHeader>
|
| 113 |
+
<CardTitle>Upload Your Creative</CardTitle>
|
| 114 |
+
<CardDescription>
|
| 115 |
+
Upload an existing ad creative to analyze and modify with new angles or concepts
|
| 116 |
+
</CardDescription>
|
| 117 |
+
</CardHeader>
|
| 118 |
+
<CardContent>
|
| 119 |
+
{/* Mode Toggle */}
|
| 120 |
+
<div className="flex gap-2 mb-6">
|
| 121 |
+
<button
|
| 122 |
+
type="button"
|
| 123 |
+
onClick={() => {
|
| 124 |
+
setUploadMode("file");
|
| 125 |
+
handleClear();
|
| 126 |
+
}}
|
| 127 |
+
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-all ${
|
| 128 |
+
uploadMode === "file"
|
| 129 |
+
? "bg-blue-500 text-white"
|
| 130 |
+
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
| 131 |
+
}`}
|
| 132 |
+
>
|
| 133 |
+
File Upload
|
| 134 |
+
</button>
|
| 135 |
+
<button
|
| 136 |
+
type="button"
|
| 137 |
+
onClick={() => {
|
| 138 |
+
setUploadMode("url");
|
| 139 |
+
handleClear();
|
| 140 |
+
}}
|
| 141 |
+
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-all ${
|
| 142 |
+
uploadMode === "url"
|
| 143 |
+
? "bg-blue-500 text-white"
|
| 144 |
+
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
| 145 |
+
}`}
|
| 146 |
+
>
|
| 147 |
+
Image URL
|
| 148 |
+
</button>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{/* Error Display */}
|
| 152 |
+
{error && (
|
| 153 |
+
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
|
| 154 |
+
{error}
|
| 155 |
+
</div>
|
| 156 |
+
)}
|
| 157 |
+
|
| 158 |
+
{/* File Upload Area */}
|
| 159 |
+
{uploadMode === "file" && !previewUrl && (
|
| 160 |
+
<div
|
| 161 |
+
onDragOver={handleDragOver}
|
| 162 |
+
onDragLeave={handleDragLeave}
|
| 163 |
+
onDrop={handleDrop}
|
| 164 |
+
onClick={() => fileInputRef.current?.click()}
|
| 165 |
+
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
|
| 166 |
+
isDragging
|
| 167 |
+
? "border-blue-500 bg-blue-50"
|
| 168 |
+
: "border-gray-300 hover:border-blue-400 hover:bg-gray-50"
|
| 169 |
+
}`}
|
| 170 |
+
>
|
| 171 |
+
<input
|
| 172 |
+
ref={fileInputRef}
|
| 173 |
+
type="file"
|
| 174 |
+
accept="image/png,image/jpeg,image/jpg,image/webp"
|
| 175 |
+
onChange={handleFileInputChange}
|
| 176 |
+
className="hidden"
|
| 177 |
+
/>
|
| 178 |
+
<div className="flex flex-col items-center gap-3">
|
| 179 |
+
<svg
|
| 180 |
+
className="w-12 h-12 text-gray-400"
|
| 181 |
+
fill="none"
|
| 182 |
+
stroke="currentColor"
|
| 183 |
+
viewBox="0 0 24 24"
|
| 184 |
+
>
|
| 185 |
+
<path
|
| 186 |
+
strokeLinecap="round"
|
| 187 |
+
strokeLinejoin="round"
|
| 188 |
+
strokeWidth={2}
|
| 189 |
+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
| 190 |
+
/>
|
| 191 |
+
</svg>
|
| 192 |
+
<div className="text-gray-600">
|
| 193 |
+
<span className="font-semibold text-blue-500">Click to upload</span> or drag and drop
|
| 194 |
+
</div>
|
| 195 |
+
<p className="text-xs text-gray-500">PNG, JPG, or WebP (max 10MB)</p>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
)}
|
| 199 |
+
|
| 200 |
+
{/* URL Input */}
|
| 201 |
+
{uploadMode === "url" && !previewUrl && (
|
| 202 |
+
<div className="space-y-4">
|
| 203 |
+
<div className="flex gap-2">
|
| 204 |
+
<input
|
| 205 |
+
type="url"
|
| 206 |
+
value={urlInput}
|
| 207 |
+
onChange={(e) => setUrlInput(e.target.value)}
|
| 208 |
+
placeholder="https://example.com/image.png"
|
| 209 |
+
className="flex-1 px-4 py-3 rounded-xl border-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
| 210 |
+
/>
|
| 211 |
+
<button
|
| 212 |
+
type="button"
|
| 213 |
+
onClick={handleUrlSubmit}
|
| 214 |
+
className="px-6 py-3 bg-blue-500 text-white font-medium rounded-xl hover:bg-blue-600 transition-colors"
|
| 215 |
+
>
|
| 216 |
+
Load
|
| 217 |
+
</button>
|
| 218 |
+
</div>
|
| 219 |
+
<p className="text-xs text-gray-500">
|
| 220 |
+
Enter a direct link to an image (PNG, JPG, or WebP)
|
| 221 |
+
</p>
|
| 222 |
+
</div>
|
| 223 |
+
)}
|
| 224 |
+
|
| 225 |
+
{/* Image Preview */}
|
| 226 |
+
{previewUrl && (
|
| 227 |
+
<div className="space-y-4">
|
| 228 |
+
<div className="relative rounded-xl overflow-hidden bg-gray-100">
|
| 229 |
+
<img
|
| 230 |
+
src={previewUrl}
|
| 231 |
+
alt="Creative preview"
|
| 232 |
+
className="w-full h-auto max-h-96 object-contain"
|
| 233 |
+
onError={() => {
|
| 234 |
+
setError("Failed to load image. Please check the URL or try a different image.");
|
| 235 |
+
setPreviewUrl(null);
|
| 236 |
+
}}
|
| 237 |
+
/>
|
| 238 |
+
</div>
|
| 239 |
+
|
| 240 |
+
<div className="flex gap-3">
|
| 241 |
+
<button
|
| 242 |
+
type="button"
|
| 243 |
+
onClick={handleClear}
|
| 244 |
+
className="flex-1 py-3 px-4 border-2 border-gray-300 text-gray-700 font-medium rounded-xl hover:bg-gray-50 transition-colors"
|
| 245 |
+
>
|
| 246 |
+
Change Image
|
| 247 |
+
</button>
|
| 248 |
+
<button
|
| 249 |
+
type="button"
|
| 250 |
+
onClick={handleContinue}
|
| 251 |
+
disabled={isLoading}
|
| 252 |
+
className="flex-1 py-3 px-4 bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-bold rounded-xl hover:from-blue-600 hover:to-cyan-600 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
| 253 |
+
>
|
| 254 |
+
{isLoading ? "Analyzing..." : "Analyze Creative"}
|
| 255 |
+
</button>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
)}
|
| 259 |
+
</CardContent>
|
| 260 |
+
</Card>
|
| 261 |
+
);
|
| 262 |
+
};
|
frontend/components/creative/ModificationForm.tsx
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card";
|
| 5 |
+
import { Select } from "@/components/ui/Select";
|
| 6 |
+
import { IMAGE_MODELS } from "@/lib/constants/models";
|
| 7 |
+
import { getAllAngles, getAllConcepts } from "@/lib/api/endpoints";
|
| 8 |
+
import type { ModificationMode, AnglesResponse, ConceptsResponse } from "@/types/api";
|
| 9 |
+
|
| 10 |
+
interface AngleOption {
|
| 11 |
+
value: string;
|
| 12 |
+
label: string;
|
| 13 |
+
trigger: string;
|
| 14 |
+
category: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
interface ConceptOption {
|
| 18 |
+
value: string;
|
| 19 |
+
label: string;
|
| 20 |
+
structure: string;
|
| 21 |
+
category: string;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
interface ModificationFormProps {
|
| 25 |
+
angle: string;
|
| 26 |
+
concept: string;
|
| 27 |
+
mode: ModificationMode;
|
| 28 |
+
imageModel: string | null;
|
| 29 |
+
onAngleChange: (value: string) => void;
|
| 30 |
+
onConceptChange: (value: string) => void;
|
| 31 |
+
onModeChange: (mode: ModificationMode) => void;
|
| 32 |
+
onImageModelChange: (model: string | null) => void;
|
| 33 |
+
onSubmit: () => void;
|
| 34 |
+
isLoading: boolean;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export const ModificationForm: React.FC<ModificationFormProps> = ({
|
| 38 |
+
angle,
|
| 39 |
+
concept,
|
| 40 |
+
mode,
|
| 41 |
+
imageModel,
|
| 42 |
+
onAngleChange,
|
| 43 |
+
onConceptChange,
|
| 44 |
+
onModeChange,
|
| 45 |
+
onImageModelChange,
|
| 46 |
+
onSubmit,
|
| 47 |
+
isLoading,
|
| 48 |
+
}) => {
|
| 49 |
+
const [angleOptions, setAngleOptions] = useState<AngleOption[]>([]);
|
| 50 |
+
const [conceptOptions, setConceptOptions] = useState<ConceptOption[]>([]);
|
| 51 |
+
const [loadingOptions, setLoadingOptions] = useState(true);
|
| 52 |
+
const [angleInputMode, setAngleInputMode] = useState<"dropdown" | "custom">("dropdown");
|
| 53 |
+
const [conceptInputMode, setConceptInputMode] = useState<"dropdown" | "custom">("dropdown");
|
| 54 |
+
|
| 55 |
+
// Fetch angles and concepts on mount
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
const fetchOptions = async () => {
|
| 58 |
+
try {
|
| 59 |
+
setLoadingOptions(true);
|
| 60 |
+
const [anglesData, conceptsData] = await Promise.all([
|
| 61 |
+
getAllAngles(),
|
| 62 |
+
getAllConcepts(),
|
| 63 |
+
]);
|
| 64 |
+
|
| 65 |
+
// Transform angles data
|
| 66 |
+
const angles: AngleOption[] = [];
|
| 67 |
+
Object.entries(anglesData.categories).forEach(([categoryKey, category]) => {
|
| 68 |
+
category.angles.forEach((a) => {
|
| 69 |
+
angles.push({
|
| 70 |
+
value: a.name,
|
| 71 |
+
label: `${a.name} (${a.trigger})`,
|
| 72 |
+
trigger: a.trigger,
|
| 73 |
+
category: category.name,
|
| 74 |
+
});
|
| 75 |
+
});
|
| 76 |
+
});
|
| 77 |
+
setAngleOptions(angles);
|
| 78 |
+
|
| 79 |
+
// Transform concepts data
|
| 80 |
+
const concepts: ConceptOption[] = [];
|
| 81 |
+
Object.entries(conceptsData.categories).forEach(([categoryKey, category]) => {
|
| 82 |
+
category.concepts.forEach((c) => {
|
| 83 |
+
concepts.push({
|
| 84 |
+
value: c.name,
|
| 85 |
+
label: `${c.name}`,
|
| 86 |
+
structure: c.structure,
|
| 87 |
+
category: category.name,
|
| 88 |
+
});
|
| 89 |
+
});
|
| 90 |
+
});
|
| 91 |
+
setConceptOptions(concepts);
|
| 92 |
+
} catch (error) {
|
| 93 |
+
console.error("Failed to fetch angles/concepts:", error);
|
| 94 |
+
} finally {
|
| 95 |
+
setLoadingOptions(false);
|
| 96 |
+
}
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
fetchOptions();
|
| 100 |
+
}, []);
|
| 101 |
+
|
| 102 |
+
const isValid = angle.trim() || concept.trim();
|
| 103 |
+
|
| 104 |
+
// Group angles by category for the dropdown
|
| 105 |
+
const groupedAngles = angleOptions.reduce((acc, angle) => {
|
| 106 |
+
if (!acc[angle.category]) {
|
| 107 |
+
acc[angle.category] = [];
|
| 108 |
+
}
|
| 109 |
+
acc[angle.category].push(angle);
|
| 110 |
+
return acc;
|
| 111 |
+
}, {} as Record<string, AngleOption[]>);
|
| 112 |
+
|
| 113 |
+
// Group concepts by category for the dropdown
|
| 114 |
+
const groupedConcepts = conceptOptions.reduce((acc, concept) => {
|
| 115 |
+
if (!acc[concept.category]) {
|
| 116 |
+
acc[concept.category] = [];
|
| 117 |
+
}
|
| 118 |
+
acc[concept.category].push(concept);
|
| 119 |
+
return acc;
|
| 120 |
+
}, {} as Record<string, ConceptOption[]>);
|
| 121 |
+
|
| 122 |
+
return (
|
| 123 |
+
<Card variant="glass">
|
| 124 |
+
<CardHeader>
|
| 125 |
+
<CardTitle>Apply New Angle / Concept</CardTitle>
|
| 126 |
+
<CardDescription>
|
| 127 |
+
Select or enter a new angle or concept to apply to your creative
|
| 128 |
+
</CardDescription>
|
| 129 |
+
</CardHeader>
|
| 130 |
+
<CardContent className="space-y-6">
|
| 131 |
+
{/* Angle Selection */}
|
| 132 |
+
<div>
|
| 133 |
+
<div className="flex items-center justify-between mb-2">
|
| 134 |
+
<label className="block text-sm font-semibold text-gray-700">
|
| 135 |
+
New Angle <span className="text-gray-400 font-normal">(psychological WHY)</span>
|
| 136 |
+
</label>
|
| 137 |
+
<div className="flex gap-1">
|
| 138 |
+
<button
|
| 139 |
+
type="button"
|
| 140 |
+
onClick={() => setAngleInputMode("dropdown")}
|
| 141 |
+
className={`px-2 py-1 text-xs rounded ${
|
| 142 |
+
angleInputMode === "dropdown"
|
| 143 |
+
? "bg-orange-100 text-orange-700"
|
| 144 |
+
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
| 145 |
+
}`}
|
| 146 |
+
>
|
| 147 |
+
Select
|
| 148 |
+
</button>
|
| 149 |
+
<button
|
| 150 |
+
type="button"
|
| 151 |
+
onClick={() => setAngleInputMode("custom")}
|
| 152 |
+
className={`px-2 py-1 text-xs rounded ${
|
| 153 |
+
angleInputMode === "custom"
|
| 154 |
+
? "bg-orange-100 text-orange-700"
|
| 155 |
+
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
| 156 |
+
}`}
|
| 157 |
+
>
|
| 158 |
+
Custom
|
| 159 |
+
</button>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
{angleInputMode === "dropdown" ? (
|
| 164 |
+
<select
|
| 165 |
+
value={angle}
|
| 166 |
+
onChange={(e) => onAngleChange(e.target.value)}
|
| 167 |
+
disabled={loadingOptions}
|
| 168 |
+
className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-all"
|
| 169 |
+
>
|
| 170 |
+
<option value="">-- Select an angle --</option>
|
| 171 |
+
{Object.entries(groupedAngles).map(([category, angles]) => (
|
| 172 |
+
<optgroup key={category} label={category}>
|
| 173 |
+
{angles.map((a) => (
|
| 174 |
+
<option key={a.value} value={a.value}>
|
| 175 |
+
{a.label}
|
| 176 |
+
</option>
|
| 177 |
+
))}
|
| 178 |
+
</optgroup>
|
| 179 |
+
))}
|
| 180 |
+
</select>
|
| 181 |
+
) : (
|
| 182 |
+
<input
|
| 183 |
+
type="text"
|
| 184 |
+
value={angle}
|
| 185 |
+
onChange={(e) => onAngleChange(e.target.value)}
|
| 186 |
+
placeholder="e.g., Fear of Missing Out, Social Proof, Urgency..."
|
| 187 |
+
className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-all"
|
| 188 |
+
/>
|
| 189 |
+
)}
|
| 190 |
+
|
| 191 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 192 |
+
The psychological trigger that motivates action
|
| 193 |
+
</p>
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
{/* Concept Selection */}
|
| 197 |
+
<div>
|
| 198 |
+
<div className="flex items-center justify-between mb-2">
|
| 199 |
+
<label className="block text-sm font-semibold text-gray-700">
|
| 200 |
+
New Concept <span className="text-gray-400 font-normal">(visual HOW)</span>
|
| 201 |
+
</label>
|
| 202 |
+
<div className="flex gap-1">
|
| 203 |
+
<button
|
| 204 |
+
type="button"
|
| 205 |
+
onClick={() => setConceptInputMode("dropdown")}
|
| 206 |
+
className={`px-2 py-1 text-xs rounded ${
|
| 207 |
+
conceptInputMode === "dropdown"
|
| 208 |
+
? "bg-green-100 text-green-700"
|
| 209 |
+
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
| 210 |
+
}`}
|
| 211 |
+
>
|
| 212 |
+
Select
|
| 213 |
+
</button>
|
| 214 |
+
<button
|
| 215 |
+
type="button"
|
| 216 |
+
onClick={() => setConceptInputMode("custom")}
|
| 217 |
+
className={`px-2 py-1 text-xs rounded ${
|
| 218 |
+
conceptInputMode === "custom"
|
| 219 |
+
? "bg-green-100 text-green-700"
|
| 220 |
+
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
| 221 |
+
}`}
|
| 222 |
+
>
|
| 223 |
+
Custom
|
| 224 |
+
</button>
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
{conceptInputMode === "dropdown" ? (
|
| 229 |
+
<select
|
| 230 |
+
value={concept}
|
| 231 |
+
onChange={(e) => onConceptChange(e.target.value)}
|
| 232 |
+
disabled={loadingOptions}
|
| 233 |
+
className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all"
|
| 234 |
+
>
|
| 235 |
+
<option value="">-- Select a concept --</option>
|
| 236 |
+
{Object.entries(groupedConcepts).map(([category, concepts]) => (
|
| 237 |
+
<optgroup key={category} label={category}>
|
| 238 |
+
{concepts.map((c) => (
|
| 239 |
+
<option key={c.value} value={c.value}>
|
| 240 |
+
{c.label} - {c.structure}
|
| 241 |
+
</option>
|
| 242 |
+
))}
|
| 243 |
+
</optgroup>
|
| 244 |
+
))}
|
| 245 |
+
</select>
|
| 246 |
+
) : (
|
| 247 |
+
<input
|
| 248 |
+
type="text"
|
| 249 |
+
value={concept}
|
| 250 |
+
onChange={(e) => onConceptChange(e.target.value)}
|
| 251 |
+
placeholder="e.g., Testimonial, Before/After, Lifestyle Scene..."
|
| 252 |
+
className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all"
|
| 253 |
+
/>
|
| 254 |
+
)}
|
| 255 |
+
|
| 256 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 257 |
+
The creative format or style to present the message
|
| 258 |
+
</p>
|
| 259 |
+
</div>
|
| 260 |
+
|
| 261 |
+
{/* Mode Selection */}
|
| 262 |
+
<div>
|
| 263 |
+
<label className="block text-sm font-semibold text-gray-700 mb-3">
|
| 264 |
+
Modification Mode
|
| 265 |
+
</label>
|
| 266 |
+
<div className="grid grid-cols-2 gap-3">
|
| 267 |
+
<button
|
| 268 |
+
type="button"
|
| 269 |
+
onClick={() => onModeChange("modify")}
|
| 270 |
+
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
| 271 |
+
mode === "modify"
|
| 272 |
+
? "border-blue-500 bg-blue-50"
|
| 273 |
+
: "border-gray-200 hover:border-gray-300"
|
| 274 |
+
}`}
|
| 275 |
+
>
|
| 276 |
+
<div className="flex items-center gap-2 mb-1">
|
| 277 |
+
<svg
|
| 278 |
+
className={`w-5 h-5 ${mode === "modify" ? "text-blue-500" : "text-gray-400"}`}
|
| 279 |
+
fill="none"
|
| 280 |
+
stroke="currentColor"
|
| 281 |
+
viewBox="0 0 24 24"
|
| 282 |
+
>
|
| 283 |
+
<path
|
| 284 |
+
strokeLinecap="round"
|
| 285 |
+
strokeLinejoin="round"
|
| 286 |
+
strokeWidth={2}
|
| 287 |
+
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
| 288 |
+
/>
|
| 289 |
+
</svg>
|
| 290 |
+
<span className="font-semibold text-gray-900">Modify</span>
|
| 291 |
+
</div>
|
| 292 |
+
<p className="text-xs text-gray-500">
|
| 293 |
+
Make targeted changes while preserving most of the original image
|
| 294 |
+
</p>
|
| 295 |
+
</button>
|
| 296 |
+
<button
|
| 297 |
+
type="button"
|
| 298 |
+
onClick={() => onModeChange("inspired")}
|
| 299 |
+
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
| 300 |
+
mode === "inspired"
|
| 301 |
+
? "border-purple-500 bg-purple-50"
|
| 302 |
+
: "border-gray-200 hover:border-gray-300"
|
| 303 |
+
}`}
|
| 304 |
+
>
|
| 305 |
+
<div className="flex items-center gap-2 mb-1">
|
| 306 |
+
<svg
|
| 307 |
+
className={`w-5 h-5 ${mode === "inspired" ? "text-purple-500" : "text-gray-400"}`}
|
| 308 |
+
fill="none"
|
| 309 |
+
stroke="currentColor"
|
| 310 |
+
viewBox="0 0 24 24"
|
| 311 |
+
>
|
| 312 |
+
<path
|
| 313 |
+
strokeLinecap="round"
|
| 314 |
+
strokeLinejoin="round"
|
| 315 |
+
strokeWidth={2}
|
| 316 |
+
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
| 317 |
+
/>
|
| 318 |
+
</svg>
|
| 319 |
+
<span className="font-semibold text-gray-900">Inspired</span>
|
| 320 |
+
</div>
|
| 321 |
+
<p className="text-xs text-gray-500">
|
| 322 |
+
Generate a completely new image inspired by the original
|
| 323 |
+
</p>
|
| 324 |
+
</button>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
|
| 328 |
+
{/* Image Model Selection */}
|
| 329 |
+
<Select
|
| 330 |
+
label="Image Model"
|
| 331 |
+
value={imageModel || ""}
|
| 332 |
+
onChange={(e) => onImageModelChange(e.target.value || null)}
|
| 333 |
+
options={IMAGE_MODELS.map((model) => ({
|
| 334 |
+
value: model.value,
|
| 335 |
+
label: model.label,
|
| 336 |
+
}))}
|
| 337 |
+
/>
|
| 338 |
+
|
| 339 |
+
{/* Info Box */}
|
| 340 |
+
<div className="bg-gradient-to-r from-blue-50 to-cyan-50 border border-blue-200 rounded-xl p-4">
|
| 341 |
+
<p className="text-sm text-gray-700">
|
| 342 |
+
<strong>
|
| 343 |
+
{mode === "modify" ? "Modify Mode:" : "Inspired Mode:"}
|
| 344 |
+
</strong>{" "}
|
| 345 |
+
{mode === "modify"
|
| 346 |
+
? "The AI will make targeted adjustments to apply your new angle/concept while keeping ~80-95% of the original image intact."
|
| 347 |
+
: "The AI will generate a completely new creative that captures the style and essence of the original while fully applying your new angle/concept."}
|
| 348 |
+
</p>
|
| 349 |
+
</div>
|
| 350 |
+
|
| 351 |
+
{/* Submit Button */}
|
| 352 |
+
<button
|
| 353 |
+
type="button"
|
| 354 |
+
onClick={onSubmit}
|
| 355 |
+
disabled={!isValid || isLoading}
|
| 356 |
+
className="w-full bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-bold py-4 px-6 rounded-xl hover:from-blue-600 hover:to-cyan-600 transition-all duration-300 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
| 357 |
+
>
|
| 358 |
+
{isLoading ? (
|
| 359 |
+
<span className="flex items-center justify-center gap-2">
|
| 360 |
+
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
| 361 |
+
<circle
|
| 362 |
+
className="opacity-25"
|
| 363 |
+
cx="12"
|
| 364 |
+
cy="12"
|
| 365 |
+
r="10"
|
| 366 |
+
stroke="currentColor"
|
| 367 |
+
strokeWidth="4"
|
| 368 |
+
fill="none"
|
| 369 |
+
/>
|
| 370 |
+
<path
|
| 371 |
+
className="opacity-75"
|
| 372 |
+
fill="currentColor"
|
| 373 |
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
| 374 |
+
/>
|
| 375 |
+
</svg>
|
| 376 |
+
Generating...
|
| 377 |
+
</span>
|
| 378 |
+
) : (
|
| 379 |
+
`Generate ${mode === "modify" ? "Modified" : "Inspired"} Creative`
|
| 380 |
+
)}
|
| 381 |
+
</button>
|
| 382 |
+
|
| 383 |
+
{!isValid && (
|
| 384 |
+
<p className="text-xs text-amber-600 text-center">
|
| 385 |
+
Please select or enter at least one angle or concept to continue
|
| 386 |
+
</p>
|
| 387 |
+
)}
|
| 388 |
+
</CardContent>
|
| 389 |
+
</Card>
|
| 390 |
+
);
|
| 391 |
+
};
|
frontend/components/creative/index.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { CreativeUploader } from "./CreativeUploader";
|
| 2 |
+
export { CreativeAnalysis } from "./CreativeAnalysis";
|
| 3 |
+
export { ModificationForm } from "./ModificationForm";
|
frontend/components/layout/Header.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
import React from "react";
|
| 4 |
import Link from "next/link";
|
| 5 |
import { usePathname, useRouter } from "next/navigation";
|
| 6 |
-
import { Home, Rocket, Grid, Layers, LogOut, User } from "lucide-react";
|
| 7 |
import { useAuthStore } from "@/store/authStore";
|
| 8 |
import { Button } from "@/components/ui/Button";
|
| 9 |
|
|
@@ -15,6 +15,7 @@ export const Header: React.FC = () => {
|
|
| 15 |
const navItems = [
|
| 16 |
{ href: "/", label: "Dashboard", icon: Home },
|
| 17 |
{ href: "/generate", label: "Generate", icon: Rocket },
|
|
|
|
| 18 |
{ href: "/gallery", label: "Gallery", icon: Grid },
|
| 19 |
{ href: "/matrix", label: "Matrix", icon: Layers },
|
| 20 |
];
|
|
|
|
| 3 |
import React from "react";
|
| 4 |
import Link from "next/link";
|
| 5 |
import { usePathname, useRouter } from "next/navigation";
|
| 6 |
+
import { Home, Rocket, Grid, Layers, LogOut, User, Wand2 } from "lucide-react";
|
| 7 |
import { useAuthStore } from "@/store/authStore";
|
| 8 |
import { Button } from "@/components/ui/Button";
|
| 9 |
|
|
|
|
| 15 |
const navItems = [
|
| 16 |
{ href: "/", label: "Dashboard", icon: Home },
|
| 17 |
{ href: "/generate", label: "Generate", icon: Rocket },
|
| 18 |
+
{ href: "/creative/modify", label: "Modify", icon: Wand2 },
|
| 19 |
{ href: "/gallery", label: "Gallery", icon: Grid },
|
| 20 |
{ href: "/matrix", label: "Matrix", icon: Layers },
|
| 21 |
];
|
frontend/lib/api/endpoints.ts
CHANGED
|
@@ -17,6 +17,11 @@ import type {
|
|
| 17 |
ImageCorrectResponse,
|
| 18 |
LoginResponse,
|
| 19 |
Niche,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
} from "../../types/api";
|
| 21 |
|
| 22 |
// Health & Info
|
|
@@ -202,3 +207,68 @@ export const downloadImageProxy = async (params: {
|
|
| 202 |
});
|
| 203 |
return response.data as Blob;
|
| 204 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
ImageCorrectResponse,
|
| 18 |
LoginResponse,
|
| 19 |
Niche,
|
| 20 |
+
CreativeAnalysisResponse,
|
| 21 |
+
CreativeModifyResponse,
|
| 22 |
+
FileUploadResponse,
|
| 23 |
+
ModificationMode,
|
| 24 |
+
CreativeAnalysisData,
|
| 25 |
} from "../../types/api";
|
| 26 |
|
| 27 |
// Health & Info
|
|
|
|
| 207 |
});
|
| 208 |
return response.data as Blob;
|
| 209 |
};
|
| 210 |
+
|
| 211 |
+
// Creative Modifier Endpoints
|
| 212 |
+
|
| 213 |
+
// Upload a creative image
|
| 214 |
+
export const uploadCreative = async (file: File): Promise<FileUploadResponse> => {
|
| 215 |
+
const formData = new FormData();
|
| 216 |
+
formData.append("file", file);
|
| 217 |
+
|
| 218 |
+
const response = await apiClient.post<FileUploadResponse>(
|
| 219 |
+
"/api/creative/upload",
|
| 220 |
+
formData,
|
| 221 |
+
{
|
| 222 |
+
headers: {
|
| 223 |
+
"Content-Type": "multipart/form-data",
|
| 224 |
+
},
|
| 225 |
+
}
|
| 226 |
+
);
|
| 227 |
+
return response.data;
|
| 228 |
+
};
|
| 229 |
+
|
| 230 |
+
// Analyze a creative image (via URL)
|
| 231 |
+
export const analyzeCreativeByUrl = async (params: {
|
| 232 |
+
image_url: string;
|
| 233 |
+
}): Promise<CreativeAnalysisResponse> => {
|
| 234 |
+
const response = await apiClient.post<CreativeAnalysisResponse>(
|
| 235 |
+
"/api/creative/analyze",
|
| 236 |
+
params
|
| 237 |
+
);
|
| 238 |
+
return response.data;
|
| 239 |
+
};
|
| 240 |
+
|
| 241 |
+
// Analyze a creative image (via file upload)
|
| 242 |
+
export const analyzeCreativeByFile = async (
|
| 243 |
+
file: File
|
| 244 |
+
): Promise<CreativeAnalysisResponse> => {
|
| 245 |
+
const formData = new FormData();
|
| 246 |
+
formData.append("file", file);
|
| 247 |
+
|
| 248 |
+
const response = await apiClient.post<CreativeAnalysisResponse>(
|
| 249 |
+
"/api/creative/analyze/upload",
|
| 250 |
+
formData,
|
| 251 |
+
{
|
| 252 |
+
headers: {
|
| 253 |
+
"Content-Type": "multipart/form-data",
|
| 254 |
+
},
|
| 255 |
+
}
|
| 256 |
+
);
|
| 257 |
+
return response.data;
|
| 258 |
+
};
|
| 259 |
+
|
| 260 |
+
// Modify a creative with new angle/concept
|
| 261 |
+
export const modifyCreative = async (params: {
|
| 262 |
+
image_url: string;
|
| 263 |
+
analysis?: CreativeAnalysisData | null;
|
| 264 |
+
angle?: string;
|
| 265 |
+
concept?: string;
|
| 266 |
+
mode: ModificationMode;
|
| 267 |
+
image_model?: string | null;
|
| 268 |
+
}): Promise<CreativeModifyResponse> => {
|
| 269 |
+
const response = await apiClient.post<CreativeModifyResponse>(
|
| 270 |
+
"/api/creative/modify",
|
| 271 |
+
params
|
| 272 |
+
);
|
| 273 |
+
return response.data;
|
| 274 |
+
};
|
frontend/types/api.ts
CHANGED
|
@@ -270,3 +270,53 @@ export interface LoginResponse {
|
|
| 270 |
username: string;
|
| 271 |
message?: string;
|
| 272 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
username: string;
|
| 271 |
message?: string;
|
| 272 |
}
|
| 273 |
+
|
| 274 |
+
// Creative Modifier Types
|
| 275 |
+
export interface CreativeAnalysisData {
|
| 276 |
+
visual_style: string;
|
| 277 |
+
color_palette: string[];
|
| 278 |
+
mood: string;
|
| 279 |
+
composition: string;
|
| 280 |
+
subject_matter: string;
|
| 281 |
+
text_content?: string | null;
|
| 282 |
+
current_angle?: string | null;
|
| 283 |
+
current_concept?: string | null;
|
| 284 |
+
target_audience?: string | null;
|
| 285 |
+
strengths: string[];
|
| 286 |
+
areas_for_improvement: string[];
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
export interface CreativeAnalysisResponse {
|
| 290 |
+
status: string;
|
| 291 |
+
analysis?: CreativeAnalysisData | null;
|
| 292 |
+
suggested_angles?: string[] | null;
|
| 293 |
+
suggested_concepts?: string[] | null;
|
| 294 |
+
error?: string | null;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
export interface ModifiedImageResult {
|
| 298 |
+
filename?: string | null;
|
| 299 |
+
filepath?: string | null;
|
| 300 |
+
image_url?: string | null;
|
| 301 |
+
r2_url?: string | null;
|
| 302 |
+
model_used?: string | null;
|
| 303 |
+
mode?: string | null;
|
| 304 |
+
applied_angle?: string | null;
|
| 305 |
+
applied_concept?: string | null;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
export interface CreativeModifyResponse {
|
| 309 |
+
status: string;
|
| 310 |
+
prompt?: string | null;
|
| 311 |
+
image?: ModifiedImageResult | null;
|
| 312 |
+
error?: string | null;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
export interface FileUploadResponse {
|
| 316 |
+
status: string;
|
| 317 |
+
image_url?: string | null;
|
| 318 |
+
filename?: string | null;
|
| 319 |
+
error?: string | null;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
export type ModificationMode = "modify" | "inspired";
|
main.py
CHANGED
|
@@ -359,6 +359,10 @@ async def api_info():
|
|
| 359 |
"GET /matrix/compatible/{angle_key}": "Get compatible concepts for angle",
|
| 360 |
"POST /extensive/generate": "Generate ad using extensive (researcher β creative director β designer β copywriter)",
|
| 361 |
"POST /api/correct": "Correct image for spelling mistakes and visual issues (requires image_id)",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
"GET /health": "Health check",
|
| 363 |
},
|
| 364 |
"supported_niches": ["home_insurance", "glp1"],
|
|
@@ -1191,6 +1195,328 @@ async def generate_extensive(
|
|
| 1191 |
raise HTTPException(status_code=500, detail=str(e))
|
| 1192 |
|
| 1193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1194 |
# =============================================================================
|
| 1195 |
# DATABASE ENDPOINTS
|
| 1196 |
# =============================================================================
|
|
|
|
| 359 |
"GET /matrix/compatible/{angle_key}": "Get compatible concepts for angle",
|
| 360 |
"POST /extensive/generate": "Generate ad using extensive (researcher β creative director β designer β copywriter)",
|
| 361 |
"POST /api/correct": "Correct image for spelling mistakes and visual issues (requires image_id)",
|
| 362 |
+
"POST /api/creative/upload": "Upload a creative image for analysis",
|
| 363 |
+
"POST /api/creative/analyze": "Analyze a creative image with AI vision (via URL)",
|
| 364 |
+
"POST /api/creative/analyze/upload": "Analyze a creative image with AI vision (via file upload)",
|
| 365 |
+
"POST /api/creative/modify": "Modify a creative with new angle/concept",
|
| 366 |
"GET /health": "Health check",
|
| 367 |
},
|
| 368 |
"supported_niches": ["home_insurance", "glp1"],
|
|
|
|
| 1195 |
raise HTTPException(status_code=500, detail=str(e))
|
| 1196 |
|
| 1197 |
|
| 1198 |
+
# =============================================================================
|
| 1199 |
+
# CREATIVE MODIFIER ENDPOINTS
|
| 1200 |
+
# =============================================================================
|
| 1201 |
+
|
| 1202 |
+
from fastapi import File, UploadFile
|
| 1203 |
+
from services.creative_modifier import creative_modifier_service
|
| 1204 |
+
|
| 1205 |
+
|
| 1206 |
+
class CreativeAnalysisData(BaseModel):
|
| 1207 |
+
"""Structured analysis of a creative."""
|
| 1208 |
+
visual_style: str
|
| 1209 |
+
color_palette: List[str]
|
| 1210 |
+
mood: str
|
| 1211 |
+
composition: str
|
| 1212 |
+
subject_matter: str
|
| 1213 |
+
text_content: Optional[str] = None
|
| 1214 |
+
current_angle: Optional[str] = None
|
| 1215 |
+
current_concept: Optional[str] = None
|
| 1216 |
+
target_audience: Optional[str] = None
|
| 1217 |
+
strengths: List[str]
|
| 1218 |
+
areas_for_improvement: List[str]
|
| 1219 |
+
|
| 1220 |
+
|
| 1221 |
+
class CreativeAnalyzeRequest(BaseModel):
|
| 1222 |
+
"""Request for creative analysis."""
|
| 1223 |
+
image_url: Optional[str] = Field(
|
| 1224 |
+
default=None,
|
| 1225 |
+
description="URL of the image to analyze (alternative to file upload)"
|
| 1226 |
+
)
|
| 1227 |
+
|
| 1228 |
+
|
| 1229 |
+
class CreativeAnalysisResponse(BaseModel):
|
| 1230 |
+
"""Response for creative analysis."""
|
| 1231 |
+
status: str
|
| 1232 |
+
analysis: Optional[CreativeAnalysisData] = None
|
| 1233 |
+
suggested_angles: Optional[List[str]] = None
|
| 1234 |
+
suggested_concepts: Optional[List[str]] = None
|
| 1235 |
+
error: Optional[str] = None
|
| 1236 |
+
|
| 1237 |
+
|
| 1238 |
+
class CreativeModifyRequest(BaseModel):
|
| 1239 |
+
"""Request for creative modification."""
|
| 1240 |
+
image_url: str = Field(
|
| 1241 |
+
description="URL of the original image"
|
| 1242 |
+
)
|
| 1243 |
+
analysis: Optional[Dict[str, Any]] = Field(
|
| 1244 |
+
default=None,
|
| 1245 |
+
description="Previous analysis data (optional)"
|
| 1246 |
+
)
|
| 1247 |
+
angle: Optional[str] = Field(
|
| 1248 |
+
default=None,
|
| 1249 |
+
description="Angle to apply to the creative"
|
| 1250 |
+
)
|
| 1251 |
+
concept: Optional[str] = Field(
|
| 1252 |
+
default=None,
|
| 1253 |
+
description="Concept to apply to the creative"
|
| 1254 |
+
)
|
| 1255 |
+
mode: Literal["modify", "inspired"] = Field(
|
| 1256 |
+
default="modify",
|
| 1257 |
+
description="Modification mode: 'modify' for image-to-image, 'inspired' for new generation"
|
| 1258 |
+
)
|
| 1259 |
+
image_model: Optional[str] = Field(
|
| 1260 |
+
default=None,
|
| 1261 |
+
description="Image generation model to use"
|
| 1262 |
+
)
|
| 1263 |
+
|
| 1264 |
+
|
| 1265 |
+
class ModifiedImageResult(BaseModel):
|
| 1266 |
+
"""Result of creative modification."""
|
| 1267 |
+
filename: Optional[str] = None
|
| 1268 |
+
filepath: Optional[str] = None
|
| 1269 |
+
image_url: Optional[str] = None
|
| 1270 |
+
r2_url: Optional[str] = None
|
| 1271 |
+
model_used: Optional[str] = None
|
| 1272 |
+
mode: Optional[str] = None
|
| 1273 |
+
applied_angle: Optional[str] = None
|
| 1274 |
+
applied_concept: Optional[str] = None
|
| 1275 |
+
|
| 1276 |
+
|
| 1277 |
+
class CreativeModifyResponse(BaseModel):
|
| 1278 |
+
"""Response for creative modification."""
|
| 1279 |
+
status: str
|
| 1280 |
+
prompt: Optional[str] = None
|
| 1281 |
+
image: Optional[ModifiedImageResult] = None
|
| 1282 |
+
error: Optional[str] = None
|
| 1283 |
+
|
| 1284 |
+
|
| 1285 |
+
class FileUploadResponse(BaseModel):
|
| 1286 |
+
"""Response for file upload."""
|
| 1287 |
+
status: str
|
| 1288 |
+
image_url: Optional[str] = None
|
| 1289 |
+
filename: Optional[str] = None
|
| 1290 |
+
error: Optional[str] = None
|
| 1291 |
+
|
| 1292 |
+
|
| 1293 |
+
@app.post("/api/creative/upload", response_model=FileUploadResponse)
|
| 1294 |
+
async def upload_creative(
|
| 1295 |
+
file: UploadFile = File(...),
|
| 1296 |
+
username: str = Depends(get_current_user)
|
| 1297 |
+
):
|
| 1298 |
+
"""
|
| 1299 |
+
Upload a creative image for analysis and modification.
|
| 1300 |
+
|
| 1301 |
+
Accepts PNG, JPG, JPEG, WebP files.
|
| 1302 |
+
Returns the uploaded image URL that can be used for subsequent analysis/modification.
|
| 1303 |
+
"""
|
| 1304 |
+
# Validate file type
|
| 1305 |
+
allowed_types = ["image/png", "image/jpeg", "image/jpg", "image/webp"]
|
| 1306 |
+
if file.content_type not in allowed_types:
|
| 1307 |
+
raise HTTPException(
|
| 1308 |
+
status_code=400,
|
| 1309 |
+
detail=f"Invalid file type. Allowed: PNG, JPG, JPEG, WebP. Got: {file.content_type}"
|
| 1310 |
+
)
|
| 1311 |
+
|
| 1312 |
+
# Check file size (max 10MB)
|
| 1313 |
+
contents = await file.read()
|
| 1314 |
+
if len(contents) > 10 * 1024 * 1024:
|
| 1315 |
+
raise HTTPException(
|
| 1316 |
+
status_code=400,
|
| 1317 |
+
detail="File too large. Maximum size is 10MB."
|
| 1318 |
+
)
|
| 1319 |
+
|
| 1320 |
+
try:
|
| 1321 |
+
# Generate filename
|
| 1322 |
+
from datetime import datetime
|
| 1323 |
+
import uuid
|
| 1324 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 1325 |
+
unique_id = uuid.uuid4().hex[:8]
|
| 1326 |
+
ext = file.filename.split(".")[-1] if file.filename else "png"
|
| 1327 |
+
filename = f"upload_{username}_{timestamp}_{unique_id}.{ext}"
|
| 1328 |
+
|
| 1329 |
+
# Try to upload to R2
|
| 1330 |
+
r2_url = None
|
| 1331 |
+
try:
|
| 1332 |
+
from services.r2_storage import get_r2_storage
|
| 1333 |
+
r2_storage = get_r2_storage()
|
| 1334 |
+
if r2_storage:
|
| 1335 |
+
r2_url = r2_storage.upload_image(
|
| 1336 |
+
image_bytes=contents,
|
| 1337 |
+
filename=filename,
|
| 1338 |
+
niche="uploads",
|
| 1339 |
+
)
|
| 1340 |
+
except Exception as e:
|
| 1341 |
+
api_logger.warning(f"R2 upload failed: {e}")
|
| 1342 |
+
|
| 1343 |
+
# Save locally as fallback
|
| 1344 |
+
local_path = None
|
| 1345 |
+
if not r2_url:
|
| 1346 |
+
local_path = os.path.join(settings.output_dir, filename)
|
| 1347 |
+
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
| 1348 |
+
with open(local_path, "wb") as f:
|
| 1349 |
+
f.write(contents)
|
| 1350 |
+
# Construct local URL
|
| 1351 |
+
r2_url = f"/images/{filename}"
|
| 1352 |
+
|
| 1353 |
+
return {
|
| 1354 |
+
"status": "success",
|
| 1355 |
+
"image_url": r2_url,
|
| 1356 |
+
"filename": filename,
|
| 1357 |
+
}
|
| 1358 |
+
except Exception as e:
|
| 1359 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1360 |
+
|
| 1361 |
+
|
| 1362 |
+
@app.post("/api/creative/analyze", response_model=CreativeAnalysisResponse)
|
| 1363 |
+
async def analyze_creative(
|
| 1364 |
+
request: CreativeAnalyzeRequest,
|
| 1365 |
+
username: str = Depends(get_current_user)
|
| 1366 |
+
):
|
| 1367 |
+
"""
|
| 1368 |
+
Analyze a creative image using AI vision (via URL).
|
| 1369 |
+
|
| 1370 |
+
Accepts image_url in request body.
|
| 1371 |
+
|
| 1372 |
+
Returns detailed analysis including visual style, mood, current angle/concept,
|
| 1373 |
+
and suggestions for new angles and concepts.
|
| 1374 |
+
"""
|
| 1375 |
+
if not request.image_url:
|
| 1376 |
+
raise HTTPException(
|
| 1377 |
+
status_code=400,
|
| 1378 |
+
detail="image_url must be provided"
|
| 1379 |
+
)
|
| 1380 |
+
|
| 1381 |
+
# Fetch image from URL
|
| 1382 |
+
try:
|
| 1383 |
+
image_bytes = await image_service.load_image(image_url=request.image_url)
|
| 1384 |
+
except Exception as e:
|
| 1385 |
+
raise HTTPException(status_code=400, detail=f"Failed to fetch image from URL: {e}")
|
| 1386 |
+
|
| 1387 |
+
if not image_bytes:
|
| 1388 |
+
raise HTTPException(status_code=400, detail="Failed to load image")
|
| 1389 |
+
|
| 1390 |
+
try:
|
| 1391 |
+
result = await creative_modifier_service.analyze_creative(image_bytes)
|
| 1392 |
+
|
| 1393 |
+
if result["status"] != "success":
|
| 1394 |
+
return CreativeAnalysisResponse(
|
| 1395 |
+
status="error",
|
| 1396 |
+
error=result.get("error", "Analysis failed")
|
| 1397 |
+
)
|
| 1398 |
+
|
| 1399 |
+
return CreativeAnalysisResponse(
|
| 1400 |
+
status="success",
|
| 1401 |
+
analysis=result.get("analysis"),
|
| 1402 |
+
suggested_angles=result.get("suggested_angles"),
|
| 1403 |
+
suggested_concepts=result.get("suggested_concepts"),
|
| 1404 |
+
)
|
| 1405 |
+
except Exception as e:
|
| 1406 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1407 |
+
|
| 1408 |
+
|
| 1409 |
+
@app.post("/api/creative/analyze/upload", response_model=CreativeAnalysisResponse)
|
| 1410 |
+
async def analyze_creative_upload(
|
| 1411 |
+
file: UploadFile = File(...),
|
| 1412 |
+
username: str = Depends(get_current_user)
|
| 1413 |
+
):
|
| 1414 |
+
"""
|
| 1415 |
+
Analyze a creative image using AI vision (via file upload).
|
| 1416 |
+
|
| 1417 |
+
Accepts file upload via multipart form.
|
| 1418 |
+
|
| 1419 |
+
Returns detailed analysis including visual style, mood, current angle/concept,
|
| 1420 |
+
and suggestions for new angles and concepts.
|
| 1421 |
+
"""
|
| 1422 |
+
# Validate file type
|
| 1423 |
+
allowed_types = ["image/png", "image/jpeg", "image/jpg", "image/webp"]
|
| 1424 |
+
if file.content_type not in allowed_types:
|
| 1425 |
+
raise HTTPException(
|
| 1426 |
+
status_code=400,
|
| 1427 |
+
detail=f"Invalid file type. Allowed: PNG, JPG, JPEG, WebP. Got: {file.content_type}"
|
| 1428 |
+
)
|
| 1429 |
+
|
| 1430 |
+
image_bytes = await file.read()
|
| 1431 |
+
|
| 1432 |
+
if not image_bytes:
|
| 1433 |
+
raise HTTPException(status_code=400, detail="Failed to load image")
|
| 1434 |
+
|
| 1435 |
+
try:
|
| 1436 |
+
result = await creative_modifier_service.analyze_creative(image_bytes)
|
| 1437 |
+
|
| 1438 |
+
if result["status"] != "success":
|
| 1439 |
+
return CreativeAnalysisResponse(
|
| 1440 |
+
status="error",
|
| 1441 |
+
error=result.get("error", "Analysis failed")
|
| 1442 |
+
)
|
| 1443 |
+
|
| 1444 |
+
return CreativeAnalysisResponse(
|
| 1445 |
+
status="success",
|
| 1446 |
+
analysis=result.get("analysis"),
|
| 1447 |
+
suggested_angles=result.get("suggested_angles"),
|
| 1448 |
+
suggested_concepts=result.get("suggested_concepts"),
|
| 1449 |
+
)
|
| 1450 |
+
except Exception as e:
|
| 1451 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1452 |
+
|
| 1453 |
+
|
| 1454 |
+
@app.post("/api/creative/modify", response_model=CreativeModifyResponse)
|
| 1455 |
+
async def modify_creative(
|
| 1456 |
+
request: CreativeModifyRequest,
|
| 1457 |
+
username: str = Depends(get_current_user)
|
| 1458 |
+
):
|
| 1459 |
+
"""
|
| 1460 |
+
Modify a creative based on user-provided angle and/or concept.
|
| 1461 |
+
|
| 1462 |
+
Modes:
|
| 1463 |
+
- 'modify': Uses image-to-image to make targeted changes while preserving most of the original
|
| 1464 |
+
- 'inspired': Generates a completely new image inspired by the original
|
| 1465 |
+
|
| 1466 |
+
At least one of angle or concept must be provided.
|
| 1467 |
+
"""
|
| 1468 |
+
if not request.angle and not request.concept:
|
| 1469 |
+
raise HTTPException(
|
| 1470 |
+
status_code=400,
|
| 1471 |
+
detail="At least one of 'angle' or 'concept' must be provided"
|
| 1472 |
+
)
|
| 1473 |
+
|
| 1474 |
+
# If no analysis provided, we need to analyze first
|
| 1475 |
+
analysis = request.analysis
|
| 1476 |
+
if not analysis:
|
| 1477 |
+
# Fetch and analyze the image
|
| 1478 |
+
try:
|
| 1479 |
+
image_bytes = await image_service.load_image(image_url=request.image_url)
|
| 1480 |
+
if not image_bytes:
|
| 1481 |
+
raise HTTPException(status_code=400, detail="Failed to load image from URL")
|
| 1482 |
+
|
| 1483 |
+
analysis_result = await creative_modifier_service.analyze_creative(image_bytes)
|
| 1484 |
+
if analysis_result["status"] != "success":
|
| 1485 |
+
raise HTTPException(
|
| 1486 |
+
status_code=500,
|
| 1487 |
+
detail=f"Failed to analyze image: {analysis_result.get('error')}"
|
| 1488 |
+
)
|
| 1489 |
+
analysis = analysis_result.get("analysis", {})
|
| 1490 |
+
except HTTPException:
|
| 1491 |
+
raise
|
| 1492 |
+
except Exception as e:
|
| 1493 |
+
raise HTTPException(status_code=500, detail=f"Failed to analyze image: {e}")
|
| 1494 |
+
|
| 1495 |
+
try:
|
| 1496 |
+
result = await creative_modifier_service.modify_creative(
|
| 1497 |
+
image_url=request.image_url,
|
| 1498 |
+
analysis=analysis,
|
| 1499 |
+
user_angle=request.angle,
|
| 1500 |
+
user_concept=request.concept,
|
| 1501 |
+
mode=request.mode,
|
| 1502 |
+
image_model=request.image_model,
|
| 1503 |
+
)
|
| 1504 |
+
|
| 1505 |
+
if result["status"] != "success":
|
| 1506 |
+
return CreativeModifyResponse(
|
| 1507 |
+
status="error",
|
| 1508 |
+
error=result.get("error", "Modification failed")
|
| 1509 |
+
)
|
| 1510 |
+
|
| 1511 |
+
return CreativeModifyResponse(
|
| 1512 |
+
status="success",
|
| 1513 |
+
prompt=result.get("prompt"),
|
| 1514 |
+
image=result.get("image"),
|
| 1515 |
+
)
|
| 1516 |
+
except Exception as e:
|
| 1517 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1518 |
+
|
| 1519 |
+
|
| 1520 |
# =============================================================================
|
| 1521 |
# DATABASE ENDPOINTS
|
| 1522 |
# =============================================================================
|
services/creative_modifier.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Creative Modifier Service
|
| 3 |
+
Analyzes uploaded creatives and generates modified versions based on user-provided angles/concepts.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import logging
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from typing import Dict, Any, Optional, Tuple, List
|
| 13 |
+
|
| 14 |
+
# Add parent directory to path for imports
|
| 15 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 16 |
+
|
| 17 |
+
from pydantic import BaseModel
|
| 18 |
+
from config import settings
|
| 19 |
+
from services.llm import llm_service
|
| 20 |
+
from services.image import image_service
|
| 21 |
+
|
| 22 |
+
# Configure logging
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.INFO,
|
| 25 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 26 |
+
datefmt='%Y-%m-%d %H:%M:%S'
|
| 27 |
+
)
|
| 28 |
+
logger = logging.getLogger("creative_modifier")
|
| 29 |
+
|
| 30 |
+
# Optional R2 storage import
|
| 31 |
+
try:
|
| 32 |
+
from services.r2_storage import get_r2_storage
|
| 33 |
+
r2_storage_available = True
|
| 34 |
+
except ImportError:
|
| 35 |
+
r2_storage_available = False
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class CreativeAnalysis(BaseModel):
|
| 39 |
+
"""Structured analysis of an uploaded creative."""
|
| 40 |
+
visual_style: str
|
| 41 |
+
color_palette: List[str]
|
| 42 |
+
mood: str
|
| 43 |
+
composition: str
|
| 44 |
+
subject_matter: str
|
| 45 |
+
text_content: Optional[str] = None
|
| 46 |
+
current_angle: Optional[str] = None
|
| 47 |
+
current_concept: Optional[str] = None
|
| 48 |
+
target_audience: Optional[str] = None
|
| 49 |
+
strengths: List[str]
|
| 50 |
+
areas_for_improvement: List[str]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class CreativeModifierService:
|
| 54 |
+
"""Service for analyzing and modifying creative images."""
|
| 55 |
+
|
| 56 |
+
def __init__(self):
|
| 57 |
+
"""Initialize the creative modifier service."""
|
| 58 |
+
self.output_dir = settings.output_dir
|
| 59 |
+
os.makedirs(self.output_dir, exist_ok=True)
|
| 60 |
+
|
| 61 |
+
def _should_save_locally(self) -> bool:
|
| 62 |
+
"""Determine if images should be saved locally based on environment settings."""
|
| 63 |
+
if settings.environment.lower() == "production":
|
| 64 |
+
return settings.save_images_locally
|
| 65 |
+
return True
|
| 66 |
+
|
| 67 |
+
def _save_image_locally(self, image_bytes: bytes, filename: str) -> Optional[str]:
|
| 68 |
+
"""Conditionally save image locally based on environment settings."""
|
| 69 |
+
if not self._should_save_locally():
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
filepath = os.path.join(self.output_dir, filename)
|
| 74 |
+
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
| 75 |
+
with open(filepath, "wb") as f:
|
| 76 |
+
f.write(image_bytes)
|
| 77 |
+
return filepath
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.warning(f"Failed to save image locally: {e}")
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
async def analyze_creative(
|
| 83 |
+
self,
|
| 84 |
+
image_bytes: bytes,
|
| 85 |
+
) -> Dict[str, Any]:
|
| 86 |
+
"""
|
| 87 |
+
Analyze an uploaded creative image using GPT-4 Vision.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
image_bytes: Image file bytes to analyze
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
Analysis results dictionary with structured data
|
| 94 |
+
"""
|
| 95 |
+
start_time = time.time()
|
| 96 |
+
logger.info("=" * 60)
|
| 97 |
+
logger.info("Starting creative analysis")
|
| 98 |
+
logger.info(f"Image size: {len(image_bytes)} bytes")
|
| 99 |
+
|
| 100 |
+
system_prompt = """You are an expert creative director and marketing analyst specializing in ad creatives.
|
| 101 |
+
Your task is to thoroughly analyze advertising images to understand their visual style, messaging strategy, and effectiveness.
|
| 102 |
+
You must provide detailed, actionable insights that can be used to modify or improve the creative."""
|
| 103 |
+
|
| 104 |
+
analysis_prompt = """Please analyze this advertising creative in detail. Provide your analysis as a JSON object with the following structure:
|
| 105 |
+
|
| 106 |
+
{
|
| 107 |
+
"visual_style": "Description of the visual style (e.g., 'photorealistic', 'illustrated', 'minimalist', 'vibrant', 'muted')",
|
| 108 |
+
"color_palette": ["List of dominant colors used"],
|
| 109 |
+
"mood": "The emotional mood conveyed (e.g., 'urgent', 'calm', 'exciting', 'trustworthy')",
|
| 110 |
+
"composition": "Description of the layout and composition (e.g., 'centered subject', 'rule of thirds', 'text-heavy')",
|
| 111 |
+
"subject_matter": "What is depicted in the image",
|
| 112 |
+
"text_content": "Any text visible in the image (or null if none)",
|
| 113 |
+
"current_angle": "The psychological angle being used (e.g., 'fear of missing out', 'social proof', 'authority')",
|
| 114 |
+
"current_concept": "The creative concept/format (e.g., 'testimonial', 'before/after', 'lifestyle shot')",
|
| 115 |
+
"target_audience": "Who this creative seems to target",
|
| 116 |
+
"strengths": ["List of what works well in this creative"],
|
| 117 |
+
"areas_for_improvement": ["List of potential improvements"]
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
Be specific and detailed in your analysis. If you cannot determine something with confidence, make your best assessment based on visual cues."""
|
| 121 |
+
|
| 122 |
+
try:
|
| 123 |
+
logger.info("Calling vision API for creative analysis...")
|
| 124 |
+
analysis_text = await llm_service.analyze_image_with_vision(
|
| 125 |
+
image_bytes=image_bytes,
|
| 126 |
+
analysis_prompt=analysis_prompt,
|
| 127 |
+
system_prompt=system_prompt,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# Parse the JSON response
|
| 131 |
+
import json
|
| 132 |
+
analysis_text = analysis_text.strip()
|
| 133 |
+
if analysis_text.startswith("```json"):
|
| 134 |
+
analysis_text = analysis_text[7:]
|
| 135 |
+
if analysis_text.startswith("```"):
|
| 136 |
+
analysis_text = analysis_text[3:]
|
| 137 |
+
if analysis_text.endswith("```"):
|
| 138 |
+
analysis_text = analysis_text[:-3]
|
| 139 |
+
|
| 140 |
+
analysis_data = json.loads(analysis_text.strip())
|
| 141 |
+
|
| 142 |
+
elapsed_time = time.time() - start_time
|
| 143 |
+
logger.info(f"β Creative analysis completed successfully in {elapsed_time:.2f}s")
|
| 144 |
+
|
| 145 |
+
# Generate suggested angles and concepts based on analysis
|
| 146 |
+
suggested_angles = self._generate_suggested_angles(analysis_data)
|
| 147 |
+
suggested_concepts = self._generate_suggested_concepts(analysis_data)
|
| 148 |
+
|
| 149 |
+
return {
|
| 150 |
+
"status": "success",
|
| 151 |
+
"analysis": analysis_data,
|
| 152 |
+
"suggested_angles": suggested_angles,
|
| 153 |
+
"suggested_concepts": suggested_concepts,
|
| 154 |
+
}
|
| 155 |
+
except json.JSONDecodeError as e:
|
| 156 |
+
logger.error(f"Failed to parse analysis JSON: {e}")
|
| 157 |
+
logger.error(f"Raw response: {analysis_text[:500]}...")
|
| 158 |
+
return {
|
| 159 |
+
"status": "error",
|
| 160 |
+
"error": f"Failed to parse analysis response: {str(e)}",
|
| 161 |
+
}
|
| 162 |
+
except Exception as e:
|
| 163 |
+
elapsed_time = time.time() - start_time
|
| 164 |
+
logger.error(f"β Creative analysis failed after {elapsed_time:.2f}s: {str(e)}")
|
| 165 |
+
return {
|
| 166 |
+
"status": "error",
|
| 167 |
+
"error": str(e),
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
def _generate_suggested_angles(self, analysis: Dict[str, Any]) -> List[str]:
|
| 171 |
+
"""Generate suggested angles based on the analysis using all available angles."""
|
| 172 |
+
from data.angles import get_all_angles, get_random_angles
|
| 173 |
+
|
| 174 |
+
current_angle = analysis.get("current_angle", "").lower()
|
| 175 |
+
|
| 176 |
+
# Get all angles from the data module
|
| 177 |
+
all_angles = get_all_angles()
|
| 178 |
+
|
| 179 |
+
# Format angles as "Name (Trigger)" for display
|
| 180 |
+
formatted_angles = []
|
| 181 |
+
for angle in all_angles:
|
| 182 |
+
formatted = f"{angle['name']} ({angle['trigger']})"
|
| 183 |
+
# Filter out angles that match the current angle
|
| 184 |
+
if current_angle and current_angle in angle['name'].lower():
|
| 185 |
+
continue
|
| 186 |
+
formatted_angles.append(formatted)
|
| 187 |
+
|
| 188 |
+
# Get diverse random angles for suggestions (from different categories)
|
| 189 |
+
random_angles = get_random_angles(count=12, diverse=True)
|
| 190 |
+
suggestions = []
|
| 191 |
+
for angle in random_angles:
|
| 192 |
+
formatted = f"{angle['name']} ({angle['trigger']})"
|
| 193 |
+
if current_angle and current_angle in angle['name'].lower():
|
| 194 |
+
continue
|
| 195 |
+
suggestions.append(formatted)
|
| 196 |
+
|
| 197 |
+
return suggestions[:8] # Return top 8 suggestions
|
| 198 |
+
|
| 199 |
+
def _generate_suggested_concepts(self, analysis: Dict[str, Any]) -> List[str]:
|
| 200 |
+
"""Generate suggested concepts based on the analysis using all available concepts."""
|
| 201 |
+
from data.concepts import get_all_concepts, get_random_concepts
|
| 202 |
+
|
| 203 |
+
current_concept = analysis.get("current_concept", "").lower()
|
| 204 |
+
|
| 205 |
+
# Get all concepts from the data module
|
| 206 |
+
all_concepts = get_all_concepts()
|
| 207 |
+
|
| 208 |
+
# Format concepts as "Name - Structure" for display
|
| 209 |
+
formatted_concepts = []
|
| 210 |
+
for concept in all_concepts:
|
| 211 |
+
formatted = f"{concept['name']} ({concept['structure']})"
|
| 212 |
+
# Filter out concepts that match the current concept
|
| 213 |
+
if current_concept and current_concept in concept['name'].lower():
|
| 214 |
+
continue
|
| 215 |
+
formatted_concepts.append(formatted)
|
| 216 |
+
|
| 217 |
+
# Get diverse random concepts for suggestions (from different categories)
|
| 218 |
+
random_concepts = get_random_concepts(count=12, diverse=True)
|
| 219 |
+
suggestions = []
|
| 220 |
+
for concept in random_concepts:
|
| 221 |
+
formatted = f"{concept['name']} ({concept['structure']})"
|
| 222 |
+
if current_concept and current_concept in concept['name'].lower():
|
| 223 |
+
continue
|
| 224 |
+
suggestions.append(formatted)
|
| 225 |
+
|
| 226 |
+
return suggestions[:8] # Return top 8 suggestions
|
| 227 |
+
|
| 228 |
+
async def generate_modification_prompt(
|
| 229 |
+
self,
|
| 230 |
+
analysis: Dict[str, Any],
|
| 231 |
+
user_angle: Optional[str] = None,
|
| 232 |
+
user_concept: Optional[str] = None,
|
| 233 |
+
mode: str = "modify",
|
| 234 |
+
) -> str:
|
| 235 |
+
"""
|
| 236 |
+
Generate a prompt for modifying the creative based on analysis and user input.
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
analysis: The creative analysis data
|
| 240 |
+
user_angle: User-provided angle to apply
|
| 241 |
+
user_concept: User-provided concept to apply
|
| 242 |
+
mode: "modify" for image-to-image, "inspired" for new generation
|
| 243 |
+
|
| 244 |
+
Returns:
|
| 245 |
+
Generated prompt string
|
| 246 |
+
"""
|
| 247 |
+
logger.info(f"Generating modification prompt (mode: {mode})")
|
| 248 |
+
logger.info(f"User angle: {user_angle}")
|
| 249 |
+
logger.info(f"User concept: {user_concept}")
|
| 250 |
+
|
| 251 |
+
system_prompt = """You are an expert prompt engineer for AI image generation models.
|
| 252 |
+
Your task is to create effective prompts that will generate or modify advertising images.
|
| 253 |
+
For 'modify' mode, create minimal, focused prompts that specify only what should change.
|
| 254 |
+
For 'inspired' mode, create comprehensive prompts that capture the essence of the original while applying new angles/concepts.
|
| 255 |
+
|
| 256 |
+
CRITICAL: ALL generated images MUST be photorealistic. Always include photorealistic quality descriptors in your prompts."""
|
| 257 |
+
|
| 258 |
+
if mode == "modify":
|
| 259 |
+
# Minimal prompt for image-to-image modification
|
| 260 |
+
prompt_request = f"""Based on this creative analysis:
|
| 261 |
+
|
| 262 |
+
Visual Style: {analysis.get('visual_style', 'Unknown')}
|
| 263 |
+
Subject Matter: {analysis.get('subject_matter', 'Unknown')}
|
| 264 |
+
Current Angle: {analysis.get('current_angle', 'Unknown')}
|
| 265 |
+
Current Concept: {analysis.get('current_concept', 'Unknown')}
|
| 266 |
+
|
| 267 |
+
The user wants to apply:
|
| 268 |
+
- New Angle: {user_angle or 'Keep current'}
|
| 269 |
+
- New Concept: {user_concept or 'Keep current'}
|
| 270 |
+
|
| 271 |
+
Generate a MINIMAL image modification prompt (under 40 words) that specifies what should change.
|
| 272 |
+
The image-to-image model will preserve most of the original image automatically.
|
| 273 |
+
Focus only on the specific changes needed to apply the new angle/concept.
|
| 274 |
+
|
| 275 |
+
CRITICAL: The output MUST be photorealistic. Include "photorealistic" or "realistic photograph" in the prompt.
|
| 276 |
+
|
| 277 |
+
Return ONLY the prompt text, no explanations."""
|
| 278 |
+
else:
|
| 279 |
+
# Full prompt for inspired generation
|
| 280 |
+
prompt_request = f"""Based on this creative analysis:
|
| 281 |
+
|
| 282 |
+
Visual Style: {analysis.get('visual_style', 'Unknown')}
|
| 283 |
+
Color Palette: {', '.join(analysis.get('color_palette', ['Unknown']))}
|
| 284 |
+
Mood: {analysis.get('mood', 'Unknown')}
|
| 285 |
+
Composition: {analysis.get('composition', 'Unknown')}
|
| 286 |
+
Subject Matter: {analysis.get('subject_matter', 'Unknown')}
|
| 287 |
+
Target Audience: {analysis.get('target_audience', 'Unknown')}
|
| 288 |
+
|
| 289 |
+
The user wants to create a NEW creative inspired by this one, applying:
|
| 290 |
+
- New Angle: {user_angle or 'Similar to original'}
|
| 291 |
+
- New Concept: {user_concept or 'Similar to original'}
|
| 292 |
+
|
| 293 |
+
Generate a COMPREHENSIVE image generation prompt (60-100 words) that:
|
| 294 |
+
1. MUST be photorealistic - include "photorealistic", "realistic photograph", "high-resolution photo" in the prompt
|
| 295 |
+
2. Captures the mood of the original
|
| 296 |
+
3. Incorporates the new angle/concept effectively
|
| 297 |
+
4. Maintains appeal to the target audience
|
| 298 |
+
5. Includes specific visual details for high-quality photorealistic generation
|
| 299 |
+
6. If people are included, specify realistic skin texture, natural lighting, authentic expressions
|
| 300 |
+
|
| 301 |
+
CRITICAL: The final image MUST look like a real photograph, not illustrated or AI-generated looking.
|
| 302 |
+
|
| 303 |
+
Return ONLY the prompt text, no explanations."""
|
| 304 |
+
|
| 305 |
+
try:
|
| 306 |
+
prompt = await llm_service.generate(
|
| 307 |
+
prompt=prompt_request,
|
| 308 |
+
system_prompt=system_prompt,
|
| 309 |
+
temperature=0.7,
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
# Clean up the response
|
| 313 |
+
prompt = prompt.strip().strip('"').strip("'")
|
| 314 |
+
|
| 315 |
+
# Ensure photorealistic is in the prompt
|
| 316 |
+
photorealistic_keywords = ["photorealistic", "realistic photograph", "real photo", "high-resolution photo"]
|
| 317 |
+
has_photorealistic = any(keyword.lower() in prompt.lower() for keyword in photorealistic_keywords)
|
| 318 |
+
|
| 319 |
+
if not has_photorealistic:
|
| 320 |
+
# Prepend photorealistic requirement
|
| 321 |
+
prompt = f"Photorealistic, realistic photograph. {prompt}"
|
| 322 |
+
logger.info("Added photorealistic prefix to prompt")
|
| 323 |
+
|
| 324 |
+
logger.info(f"Generated prompt: {prompt[:100]}...")
|
| 325 |
+
|
| 326 |
+
return prompt
|
| 327 |
+
except Exception as e:
|
| 328 |
+
logger.error(f"Failed to generate modification prompt: {e}")
|
| 329 |
+
# Fallback to a basic prompt with photorealistic requirements
|
| 330 |
+
if mode == "modify":
|
| 331 |
+
return f"Photorealistic, realistic photograph. Apply {user_angle or 'new angle'} with {user_concept or 'improved concept'}. Natural lighting, authentic details."
|
| 332 |
+
else:
|
| 333 |
+
return f"Photorealistic advertising photograph, high-resolution photo, {analysis.get('mood', 'professional')} mood, featuring {analysis.get('subject_matter', 'product')}, applying {user_angle or 'compelling angle'} through {user_concept or 'effective concept'}. Natural lighting, realistic textures, authentic appearance."
|
| 334 |
+
|
| 335 |
+
async def modify_creative(
|
| 336 |
+
self,
|
| 337 |
+
image_url: str,
|
| 338 |
+
analysis: Dict[str, Any],
|
| 339 |
+
user_angle: Optional[str] = None,
|
| 340 |
+
user_concept: Optional[str] = None,
|
| 341 |
+
mode: str = "modify",
|
| 342 |
+
image_model: Optional[str] = None,
|
| 343 |
+
width: int = 1024,
|
| 344 |
+
height: int = 1024,
|
| 345 |
+
) -> Dict[str, Any]:
|
| 346 |
+
"""
|
| 347 |
+
Modify or generate a new creative based on the original and user input.
|
| 348 |
+
|
| 349 |
+
Args:
|
| 350 |
+
image_url: URL of the original image
|
| 351 |
+
analysis: Creative analysis data
|
| 352 |
+
user_angle: User-provided angle
|
| 353 |
+
user_concept: User-provided concept
|
| 354 |
+
mode: "modify" for image-to-image, "inspired" for new generation
|
| 355 |
+
image_model: Model to use for generation
|
| 356 |
+
width: Output width
|
| 357 |
+
height: Output height
|
| 358 |
+
|
| 359 |
+
Returns:
|
| 360 |
+
Result dictionary with generated image info
|
| 361 |
+
"""
|
| 362 |
+
workflow_start = time.time()
|
| 363 |
+
logger.info("=" * 80)
|
| 364 |
+
logger.info("CREATIVE MODIFICATION WORKFLOW STARTED")
|
| 365 |
+
logger.info(f"Mode: {mode}")
|
| 366 |
+
logger.info(f"Image URL: {image_url}")
|
| 367 |
+
logger.info(f"User angle: {user_angle}")
|
| 368 |
+
logger.info(f"User concept: {user_concept}")
|
| 369 |
+
logger.info(f"Image model: {image_model}")
|
| 370 |
+
logger.info("=" * 80)
|
| 371 |
+
|
| 372 |
+
result = {
|
| 373 |
+
"status": "pending",
|
| 374 |
+
"prompt": None,
|
| 375 |
+
"image": None,
|
| 376 |
+
"error": None,
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
# Step 1: Generate modification prompt
|
| 380 |
+
logger.info("STEP 1: Generating modification prompt...")
|
| 381 |
+
prompt = await self.generate_modification_prompt(
|
| 382 |
+
analysis=analysis,
|
| 383 |
+
user_angle=user_angle,
|
| 384 |
+
user_concept=user_concept,
|
| 385 |
+
mode=mode,
|
| 386 |
+
)
|
| 387 |
+
result["prompt"] = prompt
|
| 388 |
+
logger.info(f"β Generated prompt: {prompt}")
|
| 389 |
+
|
| 390 |
+
# Step 2: Generate/modify image
|
| 391 |
+
logger.info("STEP 2: Generating modified image...")
|
| 392 |
+
try:
|
| 393 |
+
if mode == "modify":
|
| 394 |
+
# Use image-to-image with the original
|
| 395 |
+
model_key = image_model or "nano-banana"
|
| 396 |
+
logger.info(f"Using image-to-image with model: {model_key}")
|
| 397 |
+
|
| 398 |
+
image_bytes, model_used, generated_url = await image_service.generate(
|
| 399 |
+
prompt=prompt,
|
| 400 |
+
model_key=model_key,
|
| 401 |
+
width=width,
|
| 402 |
+
height=height,
|
| 403 |
+
image_url=image_url, # Pass original for image-to-image
|
| 404 |
+
)
|
| 405 |
+
else:
|
| 406 |
+
# Generate new image inspired by original
|
| 407 |
+
model_key = image_model or "nano-banana"
|
| 408 |
+
logger.info(f"Generating new image with model: {model_key}")
|
| 409 |
+
|
| 410 |
+
image_bytes, model_used, generated_url = await image_service.generate(
|
| 411 |
+
prompt=prompt,
|
| 412 |
+
model_key=model_key,
|
| 413 |
+
width=width,
|
| 414 |
+
height=height,
|
| 415 |
+
# No image_url - generate fresh
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
if not image_bytes:
|
| 419 |
+
raise Exception("Image generation returned no data")
|
| 420 |
+
|
| 421 |
+
logger.info(f"β Image generated successfully ({len(image_bytes)} bytes)")
|
| 422 |
+
|
| 423 |
+
except Exception as e:
|
| 424 |
+
logger.error(f"β Image generation failed: {e}")
|
| 425 |
+
result["status"] = "error"
|
| 426 |
+
result["error"] = f"Image generation failed: {str(e)}"
|
| 427 |
+
return result
|
| 428 |
+
|
| 429 |
+
# Step 3: Save and upload image
|
| 430 |
+
logger.info("STEP 3: Saving generated image...")
|
| 431 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 432 |
+
unique_id = uuid.uuid4().hex[:8]
|
| 433 |
+
filename = f"modified_{timestamp}_{unique_id}.png"
|
| 434 |
+
|
| 435 |
+
# Upload to R2 if available
|
| 436 |
+
r2_url = None
|
| 437 |
+
if r2_storage_available:
|
| 438 |
+
try:
|
| 439 |
+
logger.info("Uploading to R2 storage...")
|
| 440 |
+
r2_storage = get_r2_storage()
|
| 441 |
+
if r2_storage:
|
| 442 |
+
r2_url = r2_storage.upload_image(
|
| 443 |
+
image_bytes=image_bytes,
|
| 444 |
+
filename=filename,
|
| 445 |
+
niche="modified",
|
| 446 |
+
)
|
| 447 |
+
logger.info(f"β Uploaded to R2: {r2_url}")
|
| 448 |
+
except Exception as e:
|
| 449 |
+
logger.warning(f"R2 upload failed: {e}")
|
| 450 |
+
|
| 451 |
+
# Save locally if configured
|
| 452 |
+
filepath = self._save_image_locally(image_bytes, filename)
|
| 453 |
+
if filepath:
|
| 454 |
+
logger.info(f"β Saved locally: {filepath}")
|
| 455 |
+
|
| 456 |
+
# Determine final URL
|
| 457 |
+
final_url = r2_url or generated_url
|
| 458 |
+
|
| 459 |
+
result["status"] = "success"
|
| 460 |
+
result["image"] = {
|
| 461 |
+
"filename": filename,
|
| 462 |
+
"filepath": filepath,
|
| 463 |
+
"image_url": final_url,
|
| 464 |
+
"r2_url": r2_url,
|
| 465 |
+
"model_used": model_used,
|
| 466 |
+
"mode": mode,
|
| 467 |
+
"applied_angle": user_angle,
|
| 468 |
+
"applied_concept": user_concept,
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
total_time = time.time() - workflow_start
|
| 472 |
+
logger.info("=" * 80)
|
| 473 |
+
logger.info("β CREATIVE MODIFICATION COMPLETED SUCCESSFULLY")
|
| 474 |
+
logger.info(f"Total time: {total_time:.2f}s")
|
| 475 |
+
logger.info(f"Output URL: {final_url}")
|
| 476 |
+
logger.info("=" * 80)
|
| 477 |
+
|
| 478 |
+
return result
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
# Global instance
|
| 482 |
+
creative_modifier_service = CreativeModifierService()
|