Commit
·
0fbc3f9
1
Parent(s):
2bfe32d
feat: Enhance AI image generation prompts for natural, photorealistic advertising creatives by enriching context and refining modification instructions.
Browse files- frontend/app/creative/modify/page.tsx +65 -19
- frontend/components/creative/CreativeAnalysis.tsx +81 -7
- frontend/components/creative/ModificationForm.tsx +233 -244
- frontend/components/generation/CorrectionModal.tsx +129 -29
- frontend/components/generation/RegenerationModal.tsx +43 -37
- frontend/lib/api/endpoints.ts +3 -2
- main.py +29 -19
- services/creative_modifier.py +132 -76
frontend/app/creative/modify/page.tsx
CHANGED
|
@@ -4,6 +4,7 @@ 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,
|
|
@@ -15,6 +16,7 @@ import type {
|
|
| 15 |
CreativeAnalysisData,
|
| 16 |
ModificationMode,
|
| 17 |
ModifiedImageResult,
|
|
|
|
| 18 |
} from "@/types/api";
|
| 19 |
|
| 20 |
type WorkflowStep = "upload" | "analysis" | "modify" | "result";
|
|
@@ -42,6 +44,11 @@ export default function CreativeModifyPage() {
|
|
| 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);
|
|
@@ -59,7 +66,7 @@ export default function CreativeModifyPage() {
|
|
| 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,
|
|
@@ -167,7 +174,7 @@ export default function CreativeModifyPage() {
|
|
| 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") {
|
|
@@ -175,6 +182,29 @@ export default function CreativeModifyPage() {
|
|
| 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">
|
|
@@ -197,36 +227,33 @@ export default function CreativeModifyPage() {
|
|
| 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 |
-
|
| 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 |
-
|
| 220 |
-
}`}
|
| 221 |
>
|
| 222 |
<div
|
| 223 |
-
className={`flex items-center justify-center w-10 h-10 rounded-full text-sm font-medium transition-all ${
|
| 224 |
-
|
| 225 |
-
|
| 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">
|
|
@@ -236,9 +263,8 @@ export default function CreativeModifyPage() {
|
|
| 236 |
index + 1
|
| 237 |
)}
|
| 238 |
</div>
|
| 239 |
-
<span className={`text-xs font-medium ${
|
| 240 |
-
|
| 241 |
-
}`}>
|
| 242 |
{step.label}
|
| 243 |
</span>
|
| 244 |
</button>
|
|
@@ -314,7 +340,11 @@ export default function CreativeModifyPage() {
|
|
| 314 |
)}
|
| 315 |
|
| 316 |
{/* Analysis Display */}
|
| 317 |
-
<CreativeAnalysis
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
|
| 319 |
{/* Modification Form */}
|
| 320 |
<ModificationForm
|
|
@@ -496,6 +526,22 @@ export default function CreativeModifyPage() {
|
|
| 496 |
)}
|
| 497 |
</div>
|
| 498 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
</div>
|
| 500 |
);
|
| 501 |
}
|
|
|
|
| 4 |
import { CreativeUploader } from "@/components/creative/CreativeUploader";
|
| 5 |
import { CreativeAnalysis } from "@/components/creative/CreativeAnalysis";
|
| 6 |
import { ModificationForm } from "@/components/creative/ModificationForm";
|
| 7 |
+
import { CorrectionModal } from "@/components/generation/CorrectionModal";
|
| 8 |
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 9 |
import {
|
| 10 |
uploadCreative,
|
|
|
|
| 16 |
CreativeAnalysisData,
|
| 17 |
ModificationMode,
|
| 18 |
ModifiedImageResult,
|
| 19 |
+
ImageCorrectResponse,
|
| 20 |
} from "@/types/api";
|
| 21 |
|
| 22 |
type WorkflowStep = "upload" | "analysis" | "modify" | "result";
|
|
|
|
| 44 |
const [result, setResult] = useState<ModifiedImageResult | null>(null);
|
| 45 |
const [generatedPrompt, setGeneratedPrompt] = useState<string | null>(null);
|
| 46 |
|
| 47 |
+
// Correction modal state
|
| 48 |
+
const [isCorrectionModalOpen, setIsCorrectionModalOpen] = useState(false);
|
| 49 |
+
const [correctionInstructions, setCorrectionInstructions] = useState("");
|
| 50 |
+
const [uploadedAdId, setUploadedAdId] = useState<string | null>(null);
|
| 51 |
+
|
| 52 |
// Handle image ready from uploader
|
| 53 |
const handleImageReady = useCallback(async (imageUrl: string, file?: File) => {
|
| 54 |
setIsLoading(true);
|
|
|
|
| 66 |
throw new Error(uploadResponse.error || "Failed to upload image");
|
| 67 |
}
|
| 68 |
setOriginalImageUrl(uploadResponse.image_url);
|
| 69 |
+
|
| 70 |
// Now analyze using the uploaded URL
|
| 71 |
analysisResponse = await analyzeCreativeByUrl({
|
| 72 |
image_url: uploadResponse.image_url,
|
|
|
|
| 174 |
// Handle step click for navigation
|
| 175 |
const handleStepClick = useCallback((stepKey: string) => {
|
| 176 |
if (!isStepAccessible(stepKey)) return;
|
| 177 |
+
|
| 178 |
if (stepKey === "upload") {
|
| 179 |
handleBackToUpload();
|
| 180 |
} else if (stepKey === "analysis" && currentStep === "result") {
|
|
|
|
| 182 |
}
|
| 183 |
}, [currentStep, handleBackToUpload, handleBackToAnalysis]);
|
| 184 |
|
| 185 |
+
// Handle improvement click - open correction modal
|
| 186 |
+
const handleImprovementClick = useCallback((improvement: string) => {
|
| 187 |
+
setCorrectionInstructions(improvement);
|
| 188 |
+
setIsCorrectionModalOpen(true);
|
| 189 |
+
}, []);
|
| 190 |
+
|
| 191 |
+
// Handle multiple improvements - combine them and apply
|
| 192 |
+
const handleMultipleImprovementsApply = useCallback((improvements: string[]) => {
|
| 193 |
+
const combined = improvements.join(". ");
|
| 194 |
+
setCorrectionInstructions(combined);
|
| 195 |
+
setIsCorrectionModalOpen(true);
|
| 196 |
+
}, []);
|
| 197 |
+
|
| 198 |
+
// Handle correction success
|
| 199 |
+
const handleCorrectionSuccess = useCallback((correctionResult: ImageCorrectResponse, keepCorrected: boolean) => {
|
| 200 |
+
// Update the original image URL with the corrected image ONLY if user selected it
|
| 201 |
+
if (keepCorrected && correctionResult.corrected_image?.image_url) {
|
| 202 |
+
setOriginalImageUrl(correctionResult.corrected_image.image_url);
|
| 203 |
+
// Re-analyze the corrected image
|
| 204 |
+
handleImageReady(correctionResult.corrected_image.image_url);
|
| 205 |
+
}
|
| 206 |
+
}, [handleImageReady]);
|
| 207 |
+
|
| 208 |
return (
|
| 209 |
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50">
|
| 210 |
<div className="container mx-auto px-4 py-8">
|
|
|
|
| 227 |
{ key: "analysis", label: "Analysis & Modify" },
|
| 228 |
{ key: "result", label: "Result" },
|
| 229 |
].map((step, index) => {
|
| 230 |
+
const isActive = currentStep === step.key ||
|
| 231 |
(step.key === "analysis" && currentStep === "analysis");
|
| 232 |
const isCompleted = getStepIndex(currentStep) > index;
|
| 233 |
const canClick = isStepAccessible(step.key) && step.key !== currentStep;
|
| 234 |
+
|
| 235 |
return (
|
| 236 |
<React.Fragment key={step.key}>
|
| 237 |
{index > 0 && (
|
| 238 |
<div
|
| 239 |
+
className={`w-12 h-0.5 ${isCompleted || isActive ? "bg-blue-500" : "bg-gray-300"
|
| 240 |
+
}`}
|
|
|
|
| 241 |
/>
|
| 242 |
)}
|
| 243 |
<button
|
| 244 |
type="button"
|
| 245 |
onClick={() => handleStepClick(step.key)}
|
| 246 |
disabled={!canClick}
|
| 247 |
+
className={`flex flex-col items-center gap-1 transition-all ${canClick ? "cursor-pointer hover:scale-105" : "cursor-default"
|
| 248 |
+
}`}
|
|
|
|
| 249 |
>
|
| 250 |
<div
|
| 251 |
+
className={`flex items-center justify-center w-10 h-10 rounded-full text-sm font-medium transition-all ${isActive
|
| 252 |
+
? "bg-blue-500 text-white shadow-lg"
|
| 253 |
+
: isCompleted
|
|
|
|
| 254 |
? "bg-blue-100 text-blue-600 hover:bg-blue-200"
|
| 255 |
: "bg-gray-200 text-gray-500"
|
| 256 |
+
}`}
|
| 257 |
>
|
| 258 |
{isCompleted ? (
|
| 259 |
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
|
|
| 263 |
index + 1
|
| 264 |
)}
|
| 265 |
</div>
|
| 266 |
+
<span className={`text-xs font-medium ${isActive ? "text-blue-600" : isCompleted ? "text-blue-500" : "text-gray-500"
|
| 267 |
+
}`}>
|
|
|
|
| 268 |
{step.label}
|
| 269 |
</span>
|
| 270 |
</button>
|
|
|
|
| 340 |
)}
|
| 341 |
|
| 342 |
{/* Analysis Display */}
|
| 343 |
+
<CreativeAnalysis
|
| 344 |
+
analysis={analysis}
|
| 345 |
+
onImprovementClick={handleImprovementClick}
|
| 346 |
+
onMultipleImprovementsApply={handleMultipleImprovementsApply}
|
| 347 |
+
/>
|
| 348 |
|
| 349 |
{/* Modification Form */}
|
| 350 |
<ModificationForm
|
|
|
|
| 526 |
)}
|
| 527 |
</div>
|
| 528 |
</div>
|
| 529 |
+
|
| 530 |
+
{/* Correction Modal - Note: CorrectionModal expects an adId but we're using image URL */}
|
| 531 |
+
{/* For now, we'll pass a placeholder ID since the correction API needs to be adapted */}
|
| 532 |
+
{isCorrectionModalOpen && originalImageUrl && (
|
| 533 |
+
<CorrectionModal
|
| 534 |
+
isOpen={isCorrectionModalOpen}
|
| 535 |
+
onClose={() => {
|
| 536 |
+
setIsCorrectionModalOpen(false);
|
| 537 |
+
setCorrectionInstructions("");
|
| 538 |
+
}}
|
| 539 |
+
adId={uploadedAdId || "temp-id"}
|
| 540 |
+
imageUrl={originalImageUrl}
|
| 541 |
+
initialInstructions={correctionInstructions}
|
| 542 |
+
onSuccess={handleCorrectionSuccess}
|
| 543 |
+
/>
|
| 544 |
+
)}
|
| 545 |
</div>
|
| 546 |
);
|
| 547 |
}
|
frontend/components/creative/CreativeAnalysis.tsx
CHANGED
|
@@ -6,11 +6,36 @@ 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>
|
|
@@ -105,14 +130,63 @@ export const CreativeAnalysis: React.FC<CreativeAnalysisProps> = ({
|
|
| 105 |
|
| 106 |
{/* Areas for Improvement */}
|
| 107 |
<div>
|
| 108 |
-
<
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
{analysis.areas_for_improvement.map((area, index) => (
|
| 111 |
-
<li key={index}
|
| 112 |
-
<
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
</li>
|
| 117 |
))}
|
| 118 |
</ul>
|
|
|
|
| 6 |
|
| 7 |
interface CreativeAnalysisProps {
|
| 8 |
analysis: CreativeAnalysisData;
|
| 9 |
+
onImprovementClick?: (improvement: string) => void;
|
| 10 |
+
onMultipleImprovementsApply?: (improvements: string[]) => void;
|
| 11 |
}
|
| 12 |
|
| 13 |
export const CreativeAnalysis: React.FC<CreativeAnalysisProps> = ({
|
| 14 |
analysis,
|
| 15 |
+
onImprovementClick,
|
| 16 |
+
onMultipleImprovementsApply,
|
| 17 |
}) => {
|
| 18 |
+
const [selectedImprovements, setSelectedImprovements] = React.useState<Set<number>>(new Set());
|
| 19 |
+
|
| 20 |
+
const toggleImprovement = (index: number) => {
|
| 21 |
+
setSelectedImprovements(prev => {
|
| 22 |
+
const newSet = new Set(prev);
|
| 23 |
+
if (newSet.has(index)) {
|
| 24 |
+
newSet.delete(index);
|
| 25 |
+
} else {
|
| 26 |
+
newSet.add(index);
|
| 27 |
+
}
|
| 28 |
+
return newSet;
|
| 29 |
+
});
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const handleApplySelected = () => {
|
| 33 |
+
const selected = Array.from(selectedImprovements).map(idx => analysis.areas_for_improvement[idx]);
|
| 34 |
+
if (selected.length > 0 && onMultipleImprovementsApply) {
|
| 35 |
+
onMultipleImprovementsApply(selected);
|
| 36 |
+
setSelectedImprovements(new Set());
|
| 37 |
+
}
|
| 38 |
+
};
|
| 39 |
return (
|
| 40 |
<Card variant="glass">
|
| 41 |
<CardHeader>
|
|
|
|
| 130 |
|
| 131 |
{/* Areas for Improvement */}
|
| 132 |
<div>
|
| 133 |
+
<div className="flex items-center justify-between mb-2">
|
| 134 |
+
<h4 className="text-sm font-semibold text-gray-700">
|
| 135 |
+
Areas for Improvement
|
| 136 |
+
{onMultipleImprovementsApply && (
|
| 137 |
+
<span className="ml-2 text-xs text-gray-500 font-normal">
|
| 138 |
+
(Select multiple and apply together)
|
| 139 |
+
</span>
|
| 140 |
+
)}
|
| 141 |
+
</h4>
|
| 142 |
+
{onMultipleImprovementsApply && selectedImprovements.size > 0 && (
|
| 143 |
+
<button
|
| 144 |
+
type="button"
|
| 145 |
+
onClick={handleApplySelected}
|
| 146 |
+
className="px-3 py-1 bg-amber-500 text-white text-xs font-medium rounded-lg hover:bg-amber-600 transition-colors"
|
| 147 |
+
>
|
| 148 |
+
Apply {selectedImprovements.size} Selected
|
| 149 |
+
</button>
|
| 150 |
+
)}
|
| 151 |
+
</div>
|
| 152 |
+
<ul className="space-y-2">
|
| 153 |
{analysis.areas_for_improvement.map((area, index) => (
|
| 154 |
+
<li key={index}>
|
| 155 |
+
<div
|
| 156 |
+
className={`flex items-start gap-2 text-sm p-3 rounded-lg transition-all border ${
|
| 157 |
+
selectedImprovements.has(index)
|
| 158 |
+
? "bg-amber-50 border-amber-300"
|
| 159 |
+
: onMultipleImprovementsApply
|
| 160 |
+
? "hover:bg-amber-50 hover:border-amber-200 border-transparent cursor-pointer"
|
| 161 |
+
: onImprovementClick
|
| 162 |
+
? "hover:bg-amber-50 hover:border-amber-300 border-transparent cursor-pointer"
|
| 163 |
+
: "border-transparent cursor-default"
|
| 164 |
+
}`}
|
| 165 |
+
>
|
| 166 |
+
{onMultipleImprovementsApply && (
|
| 167 |
+
<input
|
| 168 |
+
type="checkbox"
|
| 169 |
+
checked={selectedImprovements.has(index)}
|
| 170 |
+
onChange={() => toggleImprovement(index)}
|
| 171 |
+
className="w-4 h-4 text-amber-600 border-gray-300 rounded focus:ring-amber-500 mt-0.5 cursor-pointer"
|
| 172 |
+
/>
|
| 173 |
+
)}
|
| 174 |
+
<svg className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
| 175 |
+
<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" />
|
| 176 |
+
</svg>
|
| 177 |
+
<span className="text-gray-600 flex-1">{area}</span>
|
| 178 |
+
{!onMultipleImprovementsApply && onImprovementClick && (
|
| 179 |
+
<button
|
| 180 |
+
type="button"
|
| 181 |
+
onClick={() => onImprovementClick(area)}
|
| 182 |
+
className="text-amber-500 hover:text-amber-600 transition-colors"
|
| 183 |
+
>
|
| 184 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 185 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
| 186 |
+
</svg>
|
| 187 |
+
</button>
|
| 188 |
+
)}
|
| 189 |
+
</div>
|
| 190 |
</li>
|
| 191 |
))}
|
| 192 |
</ul>
|
frontend/components/creative/ModificationForm.tsx
CHANGED
|
@@ -34,6 +34,8 @@ interface ModificationFormProps {
|
|
| 34 |
isLoading: boolean;
|
| 35 |
}
|
| 36 |
|
|
|
|
|
|
|
| 37 |
export const ModificationForm: React.FC<ModificationFormProps> = ({
|
| 38 |
angle,
|
| 39 |
concept,
|
|
@@ -62,13 +64,12 @@ export const ModificationForm: React.FC<ModificationFormProps> = ({
|
|
| 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:
|
| 72 |
trigger: a.trigger,
|
| 73 |
category: category.name,
|
| 74 |
});
|
|
@@ -76,13 +77,12 @@ export const ModificationForm: React.FC<ModificationFormProps> = ({
|
|
| 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:
|
| 86 |
structure: c.structure,
|
| 87 |
category: category.name,
|
| 88 |
});
|
|
@@ -101,291 +101,280 @@ export const ModificationForm: React.FC<ModificationFormProps> = ({
|
|
| 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 |
-
<
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
</CardDescription>
|
| 129 |
</CardHeader>
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
<div>
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
>
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 264 |
-
|
| 265 |
</label>
|
| 266 |
-
<div className="grid grid-cols-2 gap-
|
| 267 |
<button
|
| 268 |
type="button"
|
| 269 |
onClick={() => onModeChange("modify")}
|
| 270 |
-
className={`p-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
}`}
|
| 275 |
>
|
| 276 |
-
<div className="flex items-center
|
| 277 |
-
<
|
| 278 |
-
className=
|
| 279 |
-
|
| 280 |
-
|
| 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 |
-
<
|
| 293 |
-
|
|
|
|
| 294 |
</p>
|
| 295 |
</button>
|
|
|
|
| 296 |
<button
|
| 297 |
type="button"
|
| 298 |
onClick={() => onModeChange("inspired")}
|
| 299 |
-
className={`p-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
}`}
|
| 304 |
>
|
| 305 |
-
<div className="flex items-center
|
| 306 |
-
<
|
| 307 |
-
className=
|
| 308 |
-
|
| 309 |
-
|
| 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 |
-
<
|
| 322 |
-
|
|
|
|
| 323 |
</p>
|
| 324 |
</button>
|
| 325 |
</div>
|
| 326 |
</div>
|
| 327 |
|
| 328 |
{/* Image Model Selection */}
|
| 329 |
-
<
|
| 330 |
-
label="
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
</
|
| 345 |
-
|
| 346 |
-
|
| 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
|
| 352 |
-
<
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 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 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
|
|
|
|
|
|
| 388 |
</CardContent>
|
| 389 |
</Card>
|
| 390 |
);
|
| 391 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
isLoading: boolean;
|
| 35 |
}
|
| 36 |
|
| 37 |
+
import { Brain, Sparkles, Wand2, Lightbulb, ChevronDown, Check, Info } from "lucide-react";
|
| 38 |
+
|
| 39 |
export const ModificationForm: React.FC<ModificationFormProps> = ({
|
| 40 |
angle,
|
| 41 |
concept,
|
|
|
|
| 64 |
getAllConcepts(),
|
| 65 |
]);
|
| 66 |
|
|
|
|
| 67 |
const angles: AngleOption[] = [];
|
| 68 |
Object.entries(anglesData.categories).forEach(([categoryKey, category]) => {
|
| 69 |
category.angles.forEach((a) => {
|
| 70 |
angles.push({
|
| 71 |
value: a.name,
|
| 72 |
+
label: a.name,
|
| 73 |
trigger: a.trigger,
|
| 74 |
category: category.name,
|
| 75 |
});
|
|
|
|
| 77 |
});
|
| 78 |
setAngleOptions(angles);
|
| 79 |
|
|
|
|
| 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 |
});
|
|
|
|
| 101 |
|
| 102 |
const isValid = angle.trim() || concept.trim();
|
| 103 |
|
|
|
|
| 104 |
const groupedAngles = angleOptions.reduce((acc, angle) => {
|
| 105 |
+
if (!acc[angle.category]) acc[angle.category] = [];
|
|
|
|
|
|
|
| 106 |
acc[angle.category].push(angle);
|
| 107 |
return acc;
|
| 108 |
}, {} as Record<string, AngleOption[]>);
|
| 109 |
|
|
|
|
| 110 |
const groupedConcepts = conceptOptions.reduce((acc, concept) => {
|
| 111 |
+
if (!acc[concept.category]) acc[concept.category] = [];
|
|
|
|
|
|
|
| 112 |
acc[concept.category].push(concept);
|
| 113 |
return acc;
|
| 114 |
}, {} as Record<string, ConceptOption[]>);
|
| 115 |
|
| 116 |
return (
|
| 117 |
+
<Card variant="glass" className="overflow-hidden border-none shadow-2xl p-0">
|
| 118 |
+
<CardHeader className="bg-gradient-to-r from-blue-600/10 to-cyan-500/10 border-b border-blue-500/10 p-8 pb-10">
|
| 119 |
+
<div className="flex items-center gap-4 mb-3">
|
| 120 |
+
<div className="p-2.5 bg-blue-600 rounded-xl shadow-lg shadow-blue-500/30">
|
| 121 |
+
<Sparkles className="w-6 h-6 text-white" />
|
| 122 |
+
</div>
|
| 123 |
+
<CardTitle className="text-3xl font-black tracking-tight text-gray-900 border-none p-0 m-0 bg-transparent flex items-center">
|
| 124 |
+
Refine & Transform
|
| 125 |
+
</CardTitle>
|
| 126 |
+
</div>
|
| 127 |
+
<CardDescription className="text-gray-600 text-lg font-medium leading-relaxed max-w-2xl">
|
| 128 |
+
Apply powerful psychological angles and visual concepts to your creative.
|
| 129 |
</CardDescription>
|
| 130 |
</CardHeader>
|
| 131 |
+
|
| 132 |
+
<CardContent className="p-8 pt-10 space-y-10">
|
| 133 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 134 |
+
{/* Angle Selection */}
|
| 135 |
+
<div className="space-y-4">
|
| 136 |
+
<div className="flex items-center justify-between">
|
| 137 |
+
<div className="flex items-center gap-2">
|
| 138 |
+
<Brain className="w-4 h-4 text-orange-500" />
|
| 139 |
+
<label className="text-sm font-bold text-gray-700 uppercase tracking-wider">
|
| 140 |
+
Psychological Angle
|
| 141 |
+
</label>
|
| 142 |
+
</div>
|
| 143 |
+
<div className="inline-flex p-1 bg-gray-100 rounded-lg">
|
| 144 |
+
<button
|
| 145 |
+
type="button"
|
| 146 |
+
onClick={() => setAngleInputMode("dropdown")}
|
| 147 |
+
className={`px-3 py-1 text-xs font-bold rounded-md transition-all ${angleInputMode === "dropdown" ? "bg-white text-orange-600 shadow-sm" : "text-gray-500 hover:text-gray-700"
|
| 148 |
+
}`}
|
| 149 |
+
>
|
| 150 |
+
Presets
|
| 151 |
+
</button>
|
| 152 |
+
<button
|
| 153 |
+
type="button"
|
| 154 |
+
onClick={() => setAngleInputMode("custom")}
|
| 155 |
+
className={`px-3 py-1 text-xs font-bold rounded-md transition-all ${angleInputMode === "custom" ? "bg-white text-orange-600 shadow-sm" : "text-gray-500 hover:text-gray-700"
|
| 156 |
+
}`}
|
| 157 |
+
>
|
| 158 |
+
Custom
|
| 159 |
+
</button>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div className="relative group">
|
| 164 |
+
{angleInputMode === "dropdown" ? (
|
| 165 |
+
<div className="relative">
|
| 166 |
+
<select
|
| 167 |
+
value={angle}
|
| 168 |
+
onChange={(e) => onAngleChange(e.target.value)}
|
| 169 |
+
disabled={loadingOptions}
|
| 170 |
+
className="w-full pl-4 pr-10 py-4 rounded-xl border-2 border-gray-100 bg-gray-50/50 hover:bg-white focus:bg-white focus:border-orange-500 focus:ring-4 focus:ring-orange-500/10 transition-all appearance-none font-medium text-gray-900"
|
| 171 |
+
>
|
| 172 |
+
<option value="">Choose an angle...</option>
|
| 173 |
+
{Object.entries(groupedAngles).map(([category, angles]) => (
|
| 174 |
+
<optgroup key={category} label={category} className="font-bold text-gray-400 uppercase text-[10px]">
|
| 175 |
+
{angles.map((a) => (
|
| 176 |
+
<option key={a.value} value={a.value} className="text-gray-900 font-medium">
|
| 177 |
+
{a.label}
|
| 178 |
+
</option>
|
| 179 |
+
))}
|
| 180 |
+
</optgroup>
|
| 181 |
+
))}
|
| 182 |
+
</select>
|
| 183 |
+
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none group-focus-within:text-orange-500 transition-colors" />
|
| 184 |
+
</div>
|
| 185 |
+
) : (
|
| 186 |
+
<input
|
| 187 |
+
type="text"
|
| 188 |
+
value={angle}
|
| 189 |
+
onChange={(e) => onAngleChange(e.target.value)}
|
| 190 |
+
placeholder="e.g., Scarcity, Fear of Missing Out..."
|
| 191 |
+
className="w-full px-4 py-4 rounded-xl border-2 border-gray-100 bg-gray-50/50 hover:bg-white focus:bg-white focus:border-orange-500 focus:ring-4 focus:ring-orange-500/10 transition-all font-medium text-gray-900"
|
| 192 |
+
/>
|
| 193 |
+
)}
|
| 194 |
</div>
|
| 195 |
+
<p className="text-xs text-gray-500 flex items-center gap-1.5 px-1">
|
| 196 |
+
<Info className="w-3 h-3" />
|
| 197 |
+
The "Why" - triggers that motivate user action.
|
| 198 |
+
</p>
|
| 199 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
+
{/* Concept Selection */}
|
| 202 |
+
<div className="space-y-4">
|
| 203 |
+
<div className="flex items-center justify-between">
|
| 204 |
+
<div className="flex items-center gap-2">
|
| 205 |
+
<Lightbulb className="w-4 h-4 text-green-500" />
|
| 206 |
+
<label className="text-sm font-bold text-gray-700 uppercase tracking-wider">
|
| 207 |
+
Visual Concept
|
| 208 |
+
</label>
|
| 209 |
+
</div>
|
| 210 |
+
<div className="inline-flex p-1 bg-gray-100 rounded-lg">
|
| 211 |
+
<button
|
| 212 |
+
type="button"
|
| 213 |
+
onClick={() => setConceptInputMode("dropdown")}
|
| 214 |
+
className={`px-3 py-1 text-xs font-bold rounded-md transition-all ${conceptInputMode === "dropdown" ? "bg-white text-green-600 shadow-sm" : "text-gray-500 hover:text-gray-700"
|
| 215 |
+
}`}
|
| 216 |
+
>
|
| 217 |
+
Presets
|
| 218 |
+
</button>
|
| 219 |
+
<button
|
| 220 |
+
type="button"
|
| 221 |
+
onClick={() => setConceptInputMode("custom")}
|
| 222 |
+
className={`px-3 py-1 text-xs font-bold rounded-md transition-all ${conceptInputMode === "custom" ? "bg-white text-green-600 shadow-sm" : "text-gray-500 hover:text-gray-700"
|
| 223 |
+
}`}
|
| 224 |
+
>
|
| 225 |
+
Custom
|
| 226 |
+
</button>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
<div className="relative group">
|
| 231 |
+
{conceptInputMode === "dropdown" ? (
|
| 232 |
+
<div className="relative">
|
| 233 |
+
<select
|
| 234 |
+
value={concept}
|
| 235 |
+
onChange={(e) => onConceptChange(e.target.value)}
|
| 236 |
+
disabled={loadingOptions}
|
| 237 |
+
className="w-full pl-4 pr-10 py-4 rounded-xl border-2 border-gray-100 bg-gray-50/50 hover:bg-white focus:bg-white focus:border-green-500 focus:ring-4 focus:ring-green-500/10 transition-all appearance-none font-medium text-gray-900"
|
| 238 |
+
>
|
| 239 |
+
<option value="">Choose a concept...</option>
|
| 240 |
+
{Object.entries(groupedConcepts).map(([category, concepts]) => (
|
| 241 |
+
<optgroup key={category} label={category} className="font-bold text-gray-400 uppercase text-[10px]">
|
| 242 |
+
{concepts.map((c) => (
|
| 243 |
+
<option key={c.value} value={c.value} className="text-gray-900 font-medium">
|
| 244 |
+
{c.label}
|
| 245 |
+
</option>
|
| 246 |
+
))}
|
| 247 |
+
</optgroup>
|
| 248 |
+
))}
|
| 249 |
+
</select>
|
| 250 |
+
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none group-focus-within:text-green-500 transition-colors" />
|
| 251 |
+
</div>
|
| 252 |
+
) : (
|
| 253 |
+
<input
|
| 254 |
+
type="text"
|
| 255 |
+
value={concept}
|
| 256 |
+
onChange={(e) => onConceptChange(e.target.value)}
|
| 257 |
+
placeholder="e.g., Before/After Comparison, User Testimonial..."
|
| 258 |
+
className="w-full px-4 py-4 rounded-xl border-2 border-gray-100 bg-gray-50/50 hover:bg-white focus:bg-white focus:border-green-500 focus:ring-4 focus:ring-green-500/10 transition-all font-medium text-gray-900"
|
| 259 |
+
/>
|
| 260 |
+
)}
|
| 261 |
</div>
|
| 262 |
+
<p className="text-xs text-gray-500 flex items-center gap-1.5 px-1">
|
| 263 |
+
<Info className="w-3 h-3" />
|
| 264 |
+
The "How" - visual structure and format.
|
| 265 |
+
</p>
|
| 266 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
</div>
|
| 268 |
|
| 269 |
{/* Mode Selection */}
|
| 270 |
+
<div className="space-y-4 pt-4 border-t border-gray-100">
|
| 271 |
+
<label className="text-sm font-bold text-gray-700 uppercase tracking-wider block">
|
| 272 |
+
Transformation Mode
|
| 273 |
</label>
|
| 274 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 275 |
<button
|
| 276 |
type="button"
|
| 277 |
onClick={() => onModeChange("modify")}
|
| 278 |
+
className={`group p-5 rounded-2xl border-2 text-left transition-all ${mode === "modify"
|
| 279 |
+
? "border-blue-500 bg-blue-50/50 shadow-md ring-4 ring-blue-500/5"
|
| 280 |
+
: "border-gray-100 bg-gray-50/30 hover:border-gray-200 hover:bg-gray-50/60"
|
| 281 |
+
}`}
|
|
|
|
| 282 |
>
|
| 283 |
+
<div className="flex items-center justify-between mb-3">
|
| 284 |
+
<div className={`p-2 rounded-lg ${mode === "modify" ? "bg-blue-500 text-white" : "bg-gray-200 text-gray-500 group-hover:bg-gray-300 transition-colors"}`}>
|
| 285 |
+
<Wand2 className="w-5 h-5" />
|
| 286 |
+
</div>
|
| 287 |
+
{mode === "modify" && <Check className="w-5 h-5 text-blue-500" />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
</div>
|
| 289 |
+
<h4 className={`font-bold text-lg mb-1 ${mode === "modify" ? "text-blue-900" : "text-gray-900"}`}>Modify Image</h4>
|
| 290 |
+
<p className="text-sm text-gray-600 leading-relaxed">
|
| 291 |
+
Smart edits to existing elements. Preserves ~90% of your original creative while applying the new angle.
|
| 292 |
</p>
|
| 293 |
</button>
|
| 294 |
+
|
| 295 |
<button
|
| 296 |
type="button"
|
| 297 |
onClick={() => onModeChange("inspired")}
|
| 298 |
+
className={`group p-5 rounded-2xl border-2 text-left transition-all ${mode === "inspired"
|
| 299 |
+
? "border-purple-500 bg-purple-50/50 shadow-md ring-4 ring-purple-500/5"
|
| 300 |
+
: "border-gray-100 bg-gray-50/30 hover:border-gray-200 hover:bg-gray-50/60"
|
| 301 |
+
}`}
|
|
|
|
| 302 |
>
|
| 303 |
+
<div className="flex items-center justify-between mb-3">
|
| 304 |
+
<div className={`p-2 rounded-lg ${mode === "inspired" ? "bg-purple-500 text-white" : "bg-gray-200 text-gray-500 group-hover:bg-gray-300 transition-colors"}`}>
|
| 305 |
+
<Sparkles className="w-5 h-5" />
|
| 306 |
+
</div>
|
| 307 |
+
{mode === "inspired" && <Check className="w-5 h-5 text-purple-500" />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
</div>
|
| 309 |
+
<h4 className={`font-bold text-lg mb-1 ${mode === "inspired" ? "text-purple-900" : "text-gray-900"}`}>Inspired Creation</h4>
|
| 310 |
+
<p className="text-sm text-gray-600 leading-relaxed">
|
| 311 |
+
Generates a fresh creative from scratch using the original's style and essence as inspiration.
|
| 312 |
</p>
|
| 313 |
</button>
|
| 314 |
</div>
|
| 315 |
</div>
|
| 316 |
|
| 317 |
{/* Image Model Selection */}
|
| 318 |
+
<div className="space-y-4 pt-4 border-t border-gray-100">
|
| 319 |
+
<label className="text-sm font-bold text-gray-700 uppercase tracking-wider block">
|
| 320 |
+
AI Engine
|
| 321 |
+
</label>
|
| 322 |
+
<div className="relative group max-w-sm">
|
| 323 |
+
<select
|
| 324 |
+
value={imageModel || ""}
|
| 325 |
+
onChange={(e) => onImageModelChange(e.target.value || null)}
|
| 326 |
+
className="w-full pl-4 pr-10 py-3 rounded-xl border-2 border-gray-100 bg-gray-50/50 hover:bg-white focus:bg-white focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 transition-all appearance-none font-medium text-gray-900"
|
| 327 |
+
>
|
| 328 |
+
{IMAGE_MODELS.map((model) => (
|
| 329 |
+
<option key={model.value} value={model.value}>
|
| 330 |
+
{model.label}
|
| 331 |
+
</option>
|
| 332 |
+
))}
|
| 333 |
+
</select>
|
| 334 |
+
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none group-focus-within:text-blue-500 transition-colors" />
|
| 335 |
+
</div>
|
|
|
|
|
|
|
| 336 |
</div>
|
| 337 |
|
| 338 |
+
{/* Footer / Submit */}
|
| 339 |
+
<div className="pt-6">
|
| 340 |
+
<button
|
| 341 |
+
type="button"
|
| 342 |
+
onClick={onSubmit}
|
| 343 |
+
disabled={!isValid || isLoading}
|
| 344 |
+
className={`w-full relative group overflow-hidden bg-gradient-to-r ${mode === "modify" ? "from-blue-600 to-cyan-500" : "from-purple-600 to-pink-500"
|
| 345 |
+
} text-white font-black text-lg py-5 px-8 rounded-2xl transition-all duration-300 shadow-xl hover:shadow-2xl hover:-translate-y-1 disabled:opacity-50 disabled:translate-y-0 disabled:shadow-none`}
|
| 346 |
+
>
|
| 347 |
+
<div className="absolute inset-0 bg-white/20 translate-y-full group-hover:translate-y-0 transition-transform duration-300" />
|
| 348 |
+
<span className="flex items-center justify-center gap-3 relative z-10">
|
| 349 |
+
{isLoading ? (
|
| 350 |
+
<>
|
| 351 |
+
<Loader2 className="animate-spin h-6 w-6" />
|
| 352 |
+
Bringing your vision to life...
|
| 353 |
+
</>
|
| 354 |
+
) : (
|
| 355 |
+
<>
|
| 356 |
+
{mode === "modify" ? <Wand2 className="w-6 h-6" /> : <Sparkles className="w-6 h-6" />}
|
| 357 |
+
Generate {mode === "modify" ? "Modified" : "Inspired"} Creative
|
| 358 |
+
</>
|
| 359 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
</span>
|
| 361 |
+
</button>
|
|
|
|
|
|
|
|
|
|
| 362 |
|
| 363 |
+
{!isValid && (
|
| 364 |
+
<div className="mt-4 flex items-center justify-center gap-2 text-amber-600 animate-pulse">
|
| 365 |
+
<Info className="w-4 h-4" />
|
| 366 |
+
<p className="text-sm font-bold">Please select or enter at least one angle or concept</p>
|
| 367 |
+
</div>
|
| 368 |
+
)}
|
| 369 |
+
</div>
|
| 370 |
</CardContent>
|
| 371 |
</Card>
|
| 372 |
);
|
| 373 |
};
|
| 374 |
+
|
| 375 |
+
const Loader2 = ({ className }: { className?: string }) => (
|
| 376 |
+
<svg className={className} viewBox="0 0 24 24">
|
| 377 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
| 378 |
+
<path className="opacity-75" fill="currentColor" 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" />
|
| 379 |
+
</svg>
|
| 380 |
+
);
|
frontend/components/generation/CorrectionModal.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import React, { useState, useEffect } from "react";
|
| 4 |
-
import { X, Wand2, Image as ImageIcon, CheckCircle2, AlertCircle, Loader2, Sparkles } from "lucide-react";
|
| 5 |
import { correctImage } from "@/lib/api/endpoints";
|
| 6 |
import type { ImageCorrectResponse, AdCreativeDB } from "@/types/api";
|
| 7 |
import { ProgressBar } from "@/components/ui/ProgressBar";
|
|
@@ -13,8 +13,10 @@ interface CorrectionModalProps {
|
|
| 13 |
isOpen: boolean;
|
| 14 |
onClose: () => void;
|
| 15 |
adId: string;
|
|
|
|
|
|
|
| 16 |
ad?: AdCreativeDB | null;
|
| 17 |
-
onSuccess?: (result: ImageCorrectResponse) => void;
|
| 18 |
}
|
| 19 |
|
| 20 |
type CorrectionStep = "idle" | "input" | "analyzing" | "correcting" | "regenerating" | "complete" | "error";
|
|
@@ -23,6 +25,8 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 23 |
isOpen,
|
| 24 |
onClose,
|
| 25 |
adId,
|
|
|
|
|
|
|
| 26 |
ad,
|
| 27 |
onSuccess,
|
| 28 |
}) => {
|
|
@@ -32,6 +36,7 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 32 |
const [error, setError] = useState<string | null>(null);
|
| 33 |
const [userInstructions, setUserInstructions] = useState("");
|
| 34 |
const [useAutoAnalyze, setUseAutoAnalyze] = useState(false);
|
|
|
|
| 35 |
|
| 36 |
useEffect(() => {
|
| 37 |
if (isOpen) {
|
|
@@ -39,7 +44,7 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 39 |
setProgress(0);
|
| 40 |
setResult(null);
|
| 41 |
setError(null);
|
| 42 |
-
setUserInstructions(
|
| 43 |
setUseAutoAnalyze(false);
|
| 44 |
} else {
|
| 45 |
// Reset state when modal closes
|
|
@@ -49,6 +54,7 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 49 |
setError(null);
|
| 50 |
setUserInstructions("");
|
| 51 |
setUseAutoAnalyze(false);
|
|
|
|
| 52 |
}
|
| 53 |
}, [isOpen]);
|
| 54 |
|
|
@@ -84,8 +90,9 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 84 |
await new Promise((resolve) => setTimeout(resolve, 2000));
|
| 85 |
|
| 86 |
// Actually perform the correction
|
| 87 |
-
const response = await correctImage({
|
| 88 |
image_id: adId,
|
|
|
|
| 89 |
user_instructions: userInstructions || undefined,
|
| 90 |
auto_analyze: useAutoAnalyze,
|
| 91 |
});
|
|
@@ -154,8 +161,8 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 154 |
<div>
|
| 155 |
<h2 className="text-xl font-bold text-gray-900">Correct Image</h2>
|
| 156 |
<p className="text-sm text-gray-500">
|
| 157 |
-
{step === "input"
|
| 158 |
-
? "Specify what you want to correct"
|
| 159 |
: "Analyzing and correcting your ad creative"}
|
| 160 |
</p>
|
| 161 |
</div>
|
|
@@ -257,22 +264,104 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 257 |
</div>
|
| 258 |
</div>
|
| 259 |
|
| 260 |
-
|
| 261 |
-
<div className="
|
| 262 |
-
|
| 263 |
-
<
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
</div>
|
| 269 |
-
|
| 270 |
|
| 271 |
{/* Show corrections details */}
|
| 272 |
{result.corrections && (
|
| 273 |
<div className="space-y-4">
|
| 274 |
<h3 className="font-semibold text-gray-900 text-lg">Correction Details</h3>
|
| 275 |
-
|
| 276 |
{result.corrections.spelling_corrections && result.corrections.spelling_corrections.length > 0 && (
|
| 277 |
<div>
|
| 278 |
<h4 className="font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
|
@@ -321,11 +410,10 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 321 |
<p className="text-gray-600 mt-1">{correction.suggestion}</p>
|
| 322 |
</div>
|
| 323 |
{correction.priority && (
|
| 324 |
-
<span className={`text-xs px-2 py-1 rounded ${
|
| 325 |
-
correction.priority === "high" ? "bg-red-100 text-red-700" :
|
| 326 |
correction.priority === "medium" ? "bg-yellow-100 text-yellow-700" :
|
| 327 |
-
|
| 328 |
-
|
| 329 |
{correction.priority}
|
| 330 |
</span>
|
| 331 |
)}
|
|
@@ -345,13 +433,25 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 345 |
</div>
|
| 346 |
)}
|
| 347 |
|
| 348 |
-
{
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
</div>
|
| 354 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
</div>
|
| 356 |
)}
|
| 357 |
</div>
|
|
@@ -362,8 +462,8 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 362 |
<div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-6 py-4 rounded-b-2xl">
|
| 363 |
<div className="flex justify-end gap-3">
|
| 364 |
{step === "input" && (
|
| 365 |
-
<Button
|
| 366 |
-
onClick={handleCorrection}
|
| 367 |
variant="primary"
|
| 368 |
disabled={!userInstructions && !useAutoAnalyze}
|
| 369 |
>
|
|
@@ -380,13 +480,13 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 380 |
onClick={() => {
|
| 381 |
if (step === "complete" && result) {
|
| 382 |
// Call onSuccess when user clicks "Done" to reload the ad
|
| 383 |
-
onSuccess?.(result);
|
| 384 |
}
|
| 385 |
onClose();
|
| 386 |
}}
|
| 387 |
variant={step === "complete" ? "primary" : "secondary"}
|
| 388 |
>
|
| 389 |
-
{step === "complete" ?
|
| 390 |
</Button>
|
| 391 |
</div>
|
| 392 |
</div>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { X, Wand2, Image as ImageIcon, CheckCircle2, AlertCircle, Loader2, Sparkles, Download } from "lucide-react";
|
| 5 |
import { correctImage } from "@/lib/api/endpoints";
|
| 6 |
import type { ImageCorrectResponse, AdCreativeDB } from "@/types/api";
|
| 7 |
import { ProgressBar } from "@/components/ui/ProgressBar";
|
|
|
|
| 13 |
isOpen: boolean;
|
| 14 |
onClose: () => void;
|
| 15 |
adId: string;
|
| 16 |
+
imageUrl?: string | null;
|
| 17 |
+
initialInstructions?: string;
|
| 18 |
ad?: AdCreativeDB | null;
|
| 19 |
+
onSuccess?: (result: ImageCorrectResponse, keepCorrected: boolean) => void;
|
| 20 |
}
|
| 21 |
|
| 22 |
type CorrectionStep = "idle" | "input" | "analyzing" | "correcting" | "regenerating" | "complete" | "error";
|
|
|
|
| 25 |
isOpen,
|
| 26 |
onClose,
|
| 27 |
adId,
|
| 28 |
+
imageUrl,
|
| 29 |
+
initialInstructions = "",
|
| 30 |
ad,
|
| 31 |
onSuccess,
|
| 32 |
}) => {
|
|
|
|
| 36 |
const [error, setError] = useState<string | null>(null);
|
| 37 |
const [userInstructions, setUserInstructions] = useState("");
|
| 38 |
const [useAutoAnalyze, setUseAutoAnalyze] = useState(false);
|
| 39 |
+
const [keepCorrected, setKeepCorrected] = useState(true);
|
| 40 |
|
| 41 |
useEffect(() => {
|
| 42 |
if (isOpen) {
|
|
|
|
| 44 |
setProgress(0);
|
| 45 |
setResult(null);
|
| 46 |
setError(null);
|
| 47 |
+
setUserInstructions(initialInstructions);
|
| 48 |
setUseAutoAnalyze(false);
|
| 49 |
} else {
|
| 50 |
// Reset state when modal closes
|
|
|
|
| 54 |
setError(null);
|
| 55 |
setUserInstructions("");
|
| 56 |
setUseAutoAnalyze(false);
|
| 57 |
+
setKeepCorrected(true);
|
| 58 |
}
|
| 59 |
}, [isOpen]);
|
| 60 |
|
|
|
|
| 90 |
await new Promise((resolve) => setTimeout(resolve, 2000));
|
| 91 |
|
| 92 |
// Actually perform the correction
|
| 93 |
+
const response = await correctImage({
|
| 94 |
image_id: adId,
|
| 95 |
+
image_url: imageUrl || undefined,
|
| 96 |
user_instructions: userInstructions || undefined,
|
| 97 |
auto_analyze: useAutoAnalyze,
|
| 98 |
});
|
|
|
|
| 161 |
<div>
|
| 162 |
<h2 className="text-xl font-bold text-gray-900">Correct Image</h2>
|
| 163 |
<p className="text-sm text-gray-500">
|
| 164 |
+
{step === "input"
|
| 165 |
+
? "Specify what you want to correct"
|
| 166 |
: "Analyzing and correcting your ad creative"}
|
| 167 |
</p>
|
| 168 |
</div>
|
|
|
|
| 264 |
</div>
|
| 265 |
</div>
|
| 266 |
|
| 267 |
+
<div className="space-y-6">
|
| 268 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 269 |
+
{/* Original Image */}
|
| 270 |
+
<div className={`space-y-3 p-4 rounded-2xl border-2 transition-all ${!keepCorrected ? 'border-blue-500 bg-blue-50/50 shadow-lg' : 'border-gray-100 hover:border-gray-200'}`}>
|
| 271 |
+
<div className="flex items-center justify-between">
|
| 272 |
+
<div className="flex items-center gap-2">
|
| 273 |
+
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${!keepCorrected ? 'border-blue-500 bg-blue-500' : 'border-gray-300'}`}>
|
| 274 |
+
{!keepCorrected && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
|
| 275 |
+
</div>
|
| 276 |
+
<h3 className="font-bold text-gray-900">Original</h3>
|
| 277 |
+
</div>
|
| 278 |
+
<Button
|
| 279 |
+
variant={!keepCorrected ? "primary" : "secondary"}
|
| 280 |
+
size="sm"
|
| 281 |
+
onClick={() => setKeepCorrected(false)}
|
| 282 |
+
>
|
| 283 |
+
{!keepCorrected ? "Selected" : "Keep Original"}
|
| 284 |
+
</Button>
|
| 285 |
+
</div>
|
| 286 |
+
<div className="aspect-square relative rounded-xl overflow-hidden border border-gray-200 cursor-pointer" onClick={() => setKeepCorrected(false)}>
|
| 287 |
+
{(imageUrl || ad?.r2_url || ad?.image_url) ? (
|
| 288 |
+
<img
|
| 289 |
+
src={(imageUrl || ad?.r2_url || ad?.image_url)!}
|
| 290 |
+
alt="Original"
|
| 291 |
+
className="w-full h-full object-cover"
|
| 292 |
+
/>
|
| 293 |
+
) : (
|
| 294 |
+
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center text-gray-400">
|
| 295 |
+
<ImageIcon className="h-8 w-8 mb-2" />
|
| 296 |
+
<span className="text-xs">No image</span>
|
| 297 |
+
</div>
|
| 298 |
+
)}
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
|
| 302 |
+
{/* Corrected Image */}
|
| 303 |
+
<div className={`space-y-3 p-4 rounded-2xl border-2 transition-all ${keepCorrected ? 'border-blue-500 bg-blue-50/50 shadow-lg' : 'border-gray-100 hover:border-gray-200'}`}>
|
| 304 |
+
<div className="flex items-center justify-between">
|
| 305 |
+
<div className="flex items-center gap-2">
|
| 306 |
+
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${keepCorrected ? 'border-blue-500 bg-blue-500' : 'border-gray-300'}`}>
|
| 307 |
+
{keepCorrected && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
|
| 308 |
+
</div>
|
| 309 |
+
<h3 className="font-bold text-gray-900">Corrected</h3>
|
| 310 |
+
</div>
|
| 311 |
+
<div className="flex gap-2">
|
| 312 |
+
<Button
|
| 313 |
+
variant="secondary"
|
| 314 |
+
size="sm"
|
| 315 |
+
onClick={async (e) => {
|
| 316 |
+
e.stopPropagation();
|
| 317 |
+
try {
|
| 318 |
+
const response = await fetch(result.corrected_image!.image_url!);
|
| 319 |
+
const blob = await response.blob();
|
| 320 |
+
const url = window.URL.createObjectURL(blob);
|
| 321 |
+
const link = document.createElement("a");
|
| 322 |
+
link.href = url;
|
| 323 |
+
link.download = result.corrected_image!.filename || "corrected-image.png";
|
| 324 |
+
document.body.appendChild(link);
|
| 325 |
+
link.click();
|
| 326 |
+
document.body.removeChild(link);
|
| 327 |
+
window.URL.revokeObjectURL(url);
|
| 328 |
+
} catch (err) {
|
| 329 |
+
window.open(result.corrected_image!.image_url!, "_blank");
|
| 330 |
+
}
|
| 331 |
+
}}
|
| 332 |
+
>
|
| 333 |
+
<Download className="h-4 w-4" />
|
| 334 |
+
</Button>
|
| 335 |
+
<Button
|
| 336 |
+
variant={keepCorrected ? "primary" : "secondary"}
|
| 337 |
+
size="sm"
|
| 338 |
+
onClick={() => setKeepCorrected(true)}
|
| 339 |
+
>
|
| 340 |
+
{keepCorrected ? "Selected" : "Use Corrected"}
|
| 341 |
+
</Button>
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
<div className="aspect-square relative rounded-xl overflow-hidden border border-gray-200 cursor-pointer" onClick={() => setKeepCorrected(true)}>
|
| 345 |
+
{result.corrected_image?.image_url && (
|
| 346 |
+
<img
|
| 347 |
+
src={result.corrected_image.image_url}
|
| 348 |
+
alt="Corrected"
|
| 349 |
+
className="w-full h-full object-cover"
|
| 350 |
+
/>
|
| 351 |
+
)}
|
| 352 |
+
<div className="absolute top-2 right-2 px-2 py-1 bg-green-500 text-white text-xs font-bold rounded-md shadow-sm">
|
| 353 |
+
NEW
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
</div>
|
| 358 |
+
</div>
|
| 359 |
|
| 360 |
{/* Show corrections details */}
|
| 361 |
{result.corrections && (
|
| 362 |
<div className="space-y-4">
|
| 363 |
<h3 className="font-semibold text-gray-900 text-lg">Correction Details</h3>
|
| 364 |
+
|
| 365 |
{result.corrections.spelling_corrections && result.corrections.spelling_corrections.length > 0 && (
|
| 366 |
<div>
|
| 367 |
<h4 className="font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
|
|
|
| 410 |
<p className="text-gray-600 mt-1">{correction.suggestion}</p>
|
| 411 |
</div>
|
| 412 |
{correction.priority && (
|
| 413 |
+
<span className={`text-xs px-2 py-1 rounded ${correction.priority === "high" ? "bg-red-100 text-red-700" :
|
|
|
|
| 414 |
correction.priority === "medium" ? "bg-yellow-100 text-yellow-700" :
|
| 415 |
+
"bg-gray-100 text-gray-700"
|
| 416 |
+
}`}>
|
| 417 |
{correction.priority}
|
| 418 |
</span>
|
| 419 |
)}
|
|
|
|
| 433 |
</div>
|
| 434 |
)}
|
| 435 |
|
| 436 |
+
{result.analysis && (
|
| 437 |
+
<div className="pt-4 border-t border-gray-100">
|
| 438 |
+
<h4 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
| 439 |
+
<ImageIcon className="h-5 w-5 text-blue-500" />
|
| 440 |
+
Detailed AI Analysis
|
| 441 |
+
</h4>
|
| 442 |
+
<div className="bg-blue-50/50 border border-blue-100 rounded-xl p-4 text-sm whitespace-pre-wrap leading-relaxed text-gray-700 max-h-60 overflow-y-auto custom-scrollbar">
|
| 443 |
+
{result.analysis}
|
| 444 |
+
</div>
|
| 445 |
</div>
|
| 446 |
)}
|
| 447 |
+
|
| 448 |
+
{/* Show message if no corrections were made */}
|
| 449 |
+
{(!result.corrections.spelling_corrections || result.corrections.spelling_corrections.length === 0) &&
|
| 450 |
+
(!result.corrections.visual_corrections || result.corrections.visual_corrections.length === 0) && (
|
| 451 |
+
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm text-gray-600">
|
| 452 |
+
<p>No specific corrections were identified. The image was regenerated based on your instructions.</p>
|
| 453 |
+
</div>
|
| 454 |
+
)}
|
| 455 |
</div>
|
| 456 |
)}
|
| 457 |
</div>
|
|
|
|
| 462 |
<div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-6 py-4 rounded-b-2xl">
|
| 463 |
<div className="flex justify-end gap-3">
|
| 464 |
{step === "input" && (
|
| 465 |
+
<Button
|
| 466 |
+
onClick={handleCorrection}
|
| 467 |
variant="primary"
|
| 468 |
disabled={!userInstructions && !useAutoAnalyze}
|
| 469 |
>
|
|
|
|
| 480 |
onClick={() => {
|
| 481 |
if (step === "complete" && result) {
|
| 482 |
// Call onSuccess when user clicks "Done" to reload the ad
|
| 483 |
+
onSuccess?.(result, keepCorrected);
|
| 484 |
}
|
| 485 |
onClose();
|
| 486 |
}}
|
| 487 |
variant={step === "complete" ? "primary" : "secondary"}
|
| 488 |
>
|
| 489 |
+
{step === "complete" ? `Use ${keepCorrected ? 'Corrected' : 'Original'}` : "Close"}
|
| 490 |
</Button>
|
| 491 |
</div>
|
| 492 |
</div>
|
frontend/components/generation/RegenerationModal.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import React, { useState, useEffect } from "react";
|
| 4 |
-
import { X, RefreshCw, CheckCircle2, AlertCircle, Loader2, Sparkles, ChevronDown, Check, ArrowLeft } from "lucide-react";
|
| 5 |
import { regenerateImage, getImageModels, confirmImageSelection } from "@/lib/api/endpoints";
|
| 6 |
import type { ImageRegenerateResponse, AdCreativeDB, ImageModel } from "@/types/api";
|
| 7 |
import { ProgressBar } from "@/components/ui/ProgressBar";
|
|
@@ -103,7 +103,7 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
|
|
| 103 |
}, 400);
|
| 104 |
|
| 105 |
// Actually perform the regeneration (preview mode)
|
| 106 |
-
const response = await regenerateImage({
|
| 107 |
image_id: adId,
|
| 108 |
image_model: selectedModel,
|
| 109 |
preview_only: true,
|
|
@@ -300,12 +300,11 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
|
|
| 300 |
{/* Side by side comparison */}
|
| 301 |
<div className="grid grid-cols-2 gap-4">
|
| 302 |
{/* Original Image */}
|
| 303 |
-
<div
|
| 304 |
-
className={`relative cursor-pointer rounded-xl overflow-hidden border-4 transition-all ${
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
}`}
|
| 309 |
onClick={() => setSelectedImage("original")}
|
| 310 |
>
|
| 311 |
<div className="absolute top-3 left-3 z-10">
|
|
@@ -320,11 +319,17 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
|
|
| 320 |
</div>
|
| 321 |
</div>
|
| 322 |
)}
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
<div className="p-3 bg-gray-50">
|
| 329 |
<p className="text-xs text-gray-500">Model</p>
|
| 330 |
<p className="text-sm font-medium text-gray-900">
|
|
@@ -334,12 +339,11 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
|
|
| 334 |
</div>
|
| 335 |
|
| 336 |
{/* New Image */}
|
| 337 |
-
<div
|
| 338 |
-
className={`relative cursor-pointer rounded-xl overflow-hidden border-4 transition-all ${
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
}`}
|
| 343 |
onClick={() => setSelectedImage("new")}
|
| 344 |
>
|
| 345 |
<div className="absolute top-3 left-3 z-10">
|
|
@@ -410,8 +414,8 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
|
|
| 410 |
<div>
|
| 411 |
<h3 className="font-semibold text-green-900">Selection Saved!</h3>
|
| 412 |
<p className="text-sm text-green-700">
|
| 413 |
-
{selectedImage === "new"
|
| 414 |
-
? "Your ad has been updated with the new image."
|
| 415 |
: "The original image has been kept."}
|
| 416 |
</p>
|
| 417 |
</div>
|
|
@@ -421,15 +425,17 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
|
|
| 421 |
{/* Show the selected image */}
|
| 422 |
<div className="flex justify-center">
|
| 423 |
<div className="max-w-md">
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
selectedImage === "new"
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
className="w-full rounded-xl border border-gray-200"
|
| 432 |
-
|
|
|
|
|
|
|
| 433 |
<p className="text-center text-sm text-gray-500 mt-2">
|
| 434 |
{selectedImage === "new" ? "New image saved" : "Original image kept"}
|
| 435 |
</p>
|
|
@@ -445,12 +451,12 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
|
|
| 445 |
{/* Left side buttons */}
|
| 446 |
<div>
|
| 447 |
{step === "compare" && (
|
| 448 |
-
<Button
|
| 449 |
onClick={() => {
|
| 450 |
setStep("input");
|
| 451 |
setResult(null);
|
| 452 |
setSelectedImage(null);
|
| 453 |
-
}}
|
| 454 |
variant="secondary"
|
| 455 |
>
|
| 456 |
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
@@ -462,8 +468,8 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
|
|
| 462 |
{/* Right side buttons */}
|
| 463 |
<div className="flex gap-3">
|
| 464 |
{step === "input" && (
|
| 465 |
-
<Button
|
| 466 |
-
onClick={handleRegenerate}
|
| 467 |
variant="primary"
|
| 468 |
disabled={!selectedModel || loadingModels}
|
| 469 |
>
|
|
@@ -471,10 +477,10 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
|
|
| 471 |
Regenerate Image
|
| 472 |
</Button>
|
| 473 |
)}
|
| 474 |
-
|
| 475 |
{step === "compare" && (
|
| 476 |
-
<Button
|
| 477 |
-
onClick={handleConfirmSelection}
|
| 478 |
variant="primary"
|
| 479 |
disabled={!selectedImage || savingSelection}
|
| 480 |
>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { X, RefreshCw, CheckCircle2, AlertCircle, Loader2, Sparkles, ChevronDown, Check, ArrowLeft, Image as ImageIcon } from "lucide-react";
|
| 5 |
import { regenerateImage, getImageModels, confirmImageSelection } from "@/lib/api/endpoints";
|
| 6 |
import type { ImageRegenerateResponse, AdCreativeDB, ImageModel } from "@/types/api";
|
| 7 |
import { ProgressBar } from "@/components/ui/ProgressBar";
|
|
|
|
| 103 |
}, 400);
|
| 104 |
|
| 105 |
// Actually perform the regeneration (preview mode)
|
| 106 |
+
const response = await regenerateImage({
|
| 107 |
image_id: adId,
|
| 108 |
image_model: selectedModel,
|
| 109 |
preview_only: true,
|
|
|
|
| 300 |
{/* Side by side comparison */}
|
| 301 |
<div className="grid grid-cols-2 gap-4">
|
| 302 |
{/* Original Image */}
|
| 303 |
+
<div
|
| 304 |
+
className={`relative cursor-pointer rounded-xl overflow-hidden border-4 transition-all ${selectedImage === "original"
|
| 305 |
+
? "border-green-500 ring-4 ring-green-200"
|
| 306 |
+
: "border-gray-200 hover:border-gray-400"
|
| 307 |
+
}`}
|
|
|
|
| 308 |
onClick={() => setSelectedImage("original")}
|
| 309 |
>
|
| 310 |
<div className="absolute top-3 left-3 z-10">
|
|
|
|
| 319 |
</div>
|
| 320 |
</div>
|
| 321 |
)}
|
| 322 |
+
{(result.original_image_url || ad?.r2_url || ad?.image_url) ? (
|
| 323 |
+
<img
|
| 324 |
+
src={(result.original_image_url || ad?.r2_url || ad?.image_url)!}
|
| 325 |
+
alt="Original"
|
| 326 |
+
className="w-full aspect-square object-cover"
|
| 327 |
+
/>
|
| 328 |
+
) : (
|
| 329 |
+
<div className="w-full aspect-square bg-gray-50 flex items-center justify-center text-gray-400">
|
| 330 |
+
<ImageIcon className="h-8 w-8" />
|
| 331 |
+
</div>
|
| 332 |
+
)}
|
| 333 |
<div className="p-3 bg-gray-50">
|
| 334 |
<p className="text-xs text-gray-500">Model</p>
|
| 335 |
<p className="text-sm font-medium text-gray-900">
|
|
|
|
| 339 |
</div>
|
| 340 |
|
| 341 |
{/* New Image */}
|
| 342 |
+
<div
|
| 343 |
+
className={`relative cursor-pointer rounded-xl overflow-hidden border-4 transition-all ${selectedImage === "new"
|
| 344 |
+
? "border-green-500 ring-4 ring-green-200"
|
| 345 |
+
: "border-gray-200 hover:border-gray-400"
|
| 346 |
+
}`}
|
|
|
|
| 347 |
onClick={() => setSelectedImage("new")}
|
| 348 |
>
|
| 349 |
<div className="absolute top-3 left-3 z-10">
|
|
|
|
| 414 |
<div>
|
| 415 |
<h3 className="font-semibold text-green-900">Selection Saved!</h3>
|
| 416 |
<p className="text-sm text-green-700">
|
| 417 |
+
{selectedImage === "new"
|
| 418 |
+
? "Your ad has been updated with the new image."
|
| 419 |
: "The original image has been kept."}
|
| 420 |
</p>
|
| 421 |
</div>
|
|
|
|
| 425 |
{/* Show the selected image */}
|
| 426 |
<div className="flex justify-center">
|
| 427 |
<div className="max-w-md">
|
| 428 |
+
{((selectedImage === "new" ? result?.regenerated_image?.image_url : (result?.original_image_url || ad?.r2_url || ad?.image_url))) ? (
|
| 429 |
+
<img
|
| 430 |
+
src={(selectedImage === "new" ? result?.regenerated_image?.image_url : (result?.original_image_url || ad?.r2_url || ad?.image_url))!}
|
| 431 |
+
alt="Selected"
|
| 432 |
+
className="w-full rounded-xl border border-gray-200"
|
| 433 |
+
/>
|
| 434 |
+
) : (
|
| 435 |
+
<div className="w-full aspect-square bg-gray-50 flex items-center justify-center text-gray-400 rounded-xl border border-gray-200">
|
| 436 |
+
<ImageIcon className="h-8 w-8" />
|
| 437 |
+
</div>
|
| 438 |
+
)}
|
| 439 |
<p className="text-center text-sm text-gray-500 mt-2">
|
| 440 |
{selectedImage === "new" ? "New image saved" : "Original image kept"}
|
| 441 |
</p>
|
|
|
|
| 451 |
{/* Left side buttons */}
|
| 452 |
<div>
|
| 453 |
{step === "compare" && (
|
| 454 |
+
<Button
|
| 455 |
onClick={() => {
|
| 456 |
setStep("input");
|
| 457 |
setResult(null);
|
| 458 |
setSelectedImage(null);
|
| 459 |
+
}}
|
| 460 |
variant="secondary"
|
| 461 |
>
|
| 462 |
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
|
|
| 468 |
{/* Right side buttons */}
|
| 469 |
<div className="flex gap-3">
|
| 470 |
{step === "input" && (
|
| 471 |
+
<Button
|
| 472 |
+
onClick={handleRegenerate}
|
| 473 |
variant="primary"
|
| 474 |
disabled={!selectedModel || loadingModels}
|
| 475 |
>
|
|
|
|
| 477 |
Regenerate Image
|
| 478 |
</Button>
|
| 479 |
)}
|
| 480 |
+
|
| 481 |
{step === "compare" && (
|
| 482 |
+
<Button
|
| 483 |
+
onClick={handleConfirmSelection}
|
| 484 |
variant="primary"
|
| 485 |
disabled={!selectedImage || savingSelection}
|
| 486 |
>
|
frontend/lib/api/endpoints.ts
CHANGED
|
@@ -196,6 +196,7 @@ export const getStrategies = async (niche: Niche): Promise<any> => {
|
|
| 196 |
// Image Correction Endpoints
|
| 197 |
export const correctImage = async (params: {
|
| 198 |
image_id: string;
|
|
|
|
| 199 |
user_instructions?: string;
|
| 200 |
auto_analyze?: boolean;
|
| 201 |
}): Promise<ImageCorrectResponse> => {
|
|
@@ -275,7 +276,7 @@ export const downloadImageProxy = async (params: {
|
|
| 275 |
export const uploadCreative = async (file: File): Promise<FileUploadResponse> => {
|
| 276 |
const formData = new FormData();
|
| 277 |
formData.append("file", file);
|
| 278 |
-
|
| 279 |
const response = await apiClient.post<FileUploadResponse>(
|
| 280 |
"/api/creative/upload",
|
| 281 |
formData,
|
|
@@ -305,7 +306,7 @@ export const analyzeCreativeByFile = async (
|
|
| 305 |
): Promise<CreativeAnalysisResponse> => {
|
| 306 |
const formData = new FormData();
|
| 307 |
formData.append("file", file);
|
| 308 |
-
|
| 309 |
const response = await apiClient.post<CreativeAnalysisResponse>(
|
| 310 |
"/api/creative/analyze/upload",
|
| 311 |
formData,
|
|
|
|
| 196 |
// Image Correction Endpoints
|
| 197 |
export const correctImage = async (params: {
|
| 198 |
image_id: string;
|
| 199 |
+
image_url?: string;
|
| 200 |
user_instructions?: string;
|
| 201 |
auto_analyze?: boolean;
|
| 202 |
}): Promise<ImageCorrectResponse> => {
|
|
|
|
| 276 |
export const uploadCreative = async (file: File): Promise<FileUploadResponse> => {
|
| 277 |
const formData = new FormData();
|
| 278 |
formData.append("file", file);
|
| 279 |
+
|
| 280 |
const response = await apiClient.post<FileUploadResponse>(
|
| 281 |
"/api/creative/upload",
|
| 282 |
formData,
|
|
|
|
| 306 |
): Promise<CreativeAnalysisResponse> => {
|
| 307 |
const formData = new FormData();
|
| 308 |
formData.append("file", file);
|
| 309 |
+
|
| 310 |
const response = await apiClient.post<CreativeAnalysisResponse>(
|
| 311 |
"/api/creative/analyze/upload",
|
| 312 |
formData,
|
main.py
CHANGED
|
@@ -700,7 +700,11 @@ async def download_image_proxy(
|
|
| 700 |
class ImageCorrectRequest(BaseModel):
|
| 701 |
"""Request schema for image correction."""
|
| 702 |
image_id: str = Field(
|
| 703 |
-
description="ID of existing ad creative in database"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
)
|
| 705 |
user_instructions: Optional[str] = Field(
|
| 706 |
default=None,
|
|
@@ -780,21 +784,27 @@ async def correct_image(
|
|
| 780 |
|
| 781 |
try:
|
| 782 |
# Fetch ad from database to get image and metadata (only if it belongs to current user)
|
| 783 |
-
|
| 784 |
-
ad =
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
|
| 791 |
-
# Get image URL from ad (prefer R2 URL, fallback to image_url)
|
| 792 |
-
image_url = ad.get("r2_url") or ad.get("image_url")
|
| 793 |
if not image_url:
|
| 794 |
-
api_logger.error(f"Image URL not found for
|
| 795 |
raise HTTPException(
|
| 796 |
-
status_code=
|
| 797 |
-
detail="Image URL not found for
|
| 798 |
)
|
| 799 |
|
| 800 |
api_logger.info(f"Image URL: {image_url}")
|
|
@@ -802,23 +812,23 @@ async def correct_image(
|
|
| 802 |
# Load image bytes for analysis (needed for vision API)
|
| 803 |
api_logger.info("Loading image bytes for analysis...")
|
| 804 |
image_bytes = await image_service.load_image(
|
| 805 |
-
image_id=request.image_id,
|
| 806 |
-
image_url=
|
| 807 |
image_bytes=None,
|
| 808 |
filepath=None,
|
| 809 |
)
|
| 810 |
|
| 811 |
if not image_bytes:
|
| 812 |
-
api_logger.error(f"Failed to load image bytes for
|
| 813 |
raise HTTPException(
|
| 814 |
status_code=404,
|
| 815 |
-
detail="Image not found for analysis"
|
| 816 |
)
|
| 817 |
|
| 818 |
api_logger.info(f"Image bytes loaded: {len(image_bytes)} bytes")
|
| 819 |
|
| 820 |
# Get original prompt from ad metadata if available
|
| 821 |
-
original_prompt = ad.get("image_prompt") or None
|
| 822 |
if original_prompt:
|
| 823 |
api_logger.info(f"Original prompt available: {len(original_prompt)} characters")
|
| 824 |
|
|
@@ -830,7 +840,7 @@ async def correct_image(
|
|
| 830 |
original_prompt=original_prompt,
|
| 831 |
width=1024,
|
| 832 |
height=1024,
|
| 833 |
-
niche=ad.get("niche"),
|
| 834 |
user_instructions=request.user_instructions,
|
| 835 |
auto_analyze=request.auto_analyze,
|
| 836 |
)
|
|
|
|
| 700 |
class ImageCorrectRequest(BaseModel):
|
| 701 |
"""Request schema for image correction."""
|
| 702 |
image_id: str = Field(
|
| 703 |
+
description="ID of existing ad creative in database, or 'temp-id' for images not in DB"
|
| 704 |
+
)
|
| 705 |
+
image_url: Optional[str] = Field(
|
| 706 |
+
default=None,
|
| 707 |
+
description="Optional image URL for images not in DB (required if image_id='temp-id')"
|
| 708 |
)
|
| 709 |
user_instructions: Optional[str] = Field(
|
| 710 |
default=None,
|
|
|
|
| 784 |
|
| 785 |
try:
|
| 786 |
# Fetch ad from database to get image and metadata (only if it belongs to current user)
|
| 787 |
+
image_url = request.image_url
|
| 788 |
+
ad = None
|
| 789 |
+
|
| 790 |
+
if request.image_id != "temp-id":
|
| 791 |
+
api_logger.info(f"Fetching ad creative from database...")
|
| 792 |
+
ad = await db_service.get_ad_creative(request.image_id, username=username)
|
| 793 |
+
if not ad:
|
| 794 |
+
api_logger.error(f"Ad creative {request.image_id} not found or access denied for user {username}")
|
| 795 |
+
raise HTTPException(status_code=404, detail=f"Ad creative with ID {request.image_id} not found or access denied")
|
| 796 |
+
|
| 797 |
+
api_logger.info(f"Ad creative found: {ad.get('title', 'N/A')} (niche: {ad.get('niche', 'N/A')})")
|
| 798 |
+
|
| 799 |
+
# Get image URL from ad if not provided in request
|
| 800 |
+
if not image_url:
|
| 801 |
+
image_url = ad.get("r2_url") or ad.get("image_url")
|
| 802 |
|
|
|
|
|
|
|
| 803 |
if not image_url:
|
| 804 |
+
api_logger.error(f"Image URL not found for request")
|
| 805 |
raise HTTPException(
|
| 806 |
+
status_code=400,
|
| 807 |
+
detail="Image URL must be provided for images not in database, or found in database for provided ID"
|
| 808 |
)
|
| 809 |
|
| 810 |
api_logger.info(f"Image URL: {image_url}")
|
|
|
|
| 812 |
# Load image bytes for analysis (needed for vision API)
|
| 813 |
api_logger.info("Loading image bytes for analysis...")
|
| 814 |
image_bytes = await image_service.load_image(
|
| 815 |
+
image_id=request.image_id if request.image_id != "temp-id" else None,
|
| 816 |
+
image_url=image_url,
|
| 817 |
image_bytes=None,
|
| 818 |
filepath=None,
|
| 819 |
)
|
| 820 |
|
| 821 |
if not image_bytes:
|
| 822 |
+
api_logger.error(f"Failed to load image bytes for request")
|
| 823 |
raise HTTPException(
|
| 824 |
status_code=404,
|
| 825 |
+
detail="Image not found for analysis. Please ensure the URL is accessible."
|
| 826 |
)
|
| 827 |
|
| 828 |
api_logger.info(f"Image bytes loaded: {len(image_bytes)} bytes")
|
| 829 |
|
| 830 |
# Get original prompt from ad metadata if available
|
| 831 |
+
original_prompt = ad.get("image_prompt") or None if ad else None
|
| 832 |
if original_prompt:
|
| 833 |
api_logger.info(f"Original prompt available: {len(original_prompt)} characters")
|
| 834 |
|
|
|
|
| 840 |
original_prompt=original_prompt,
|
| 841 |
width=1024,
|
| 842 |
height=1024,
|
| 843 |
+
niche=ad.get("niche") if ad else "others",
|
| 844 |
user_instructions=request.user_instructions,
|
| 845 |
auto_analyze=request.auto_analyze,
|
| 846 |
)
|
services/creative_modifier.py
CHANGED
|
@@ -248,59 +248,90 @@ Be specific and detailed in your analysis. If you cannot determine something wit
|
|
| 248 |
logger.info(f"User angle: {user_angle}")
|
| 249 |
logger.info(f"User concept: {user_concept}")
|
| 250 |
|
| 251 |
-
system_prompt = """You are an expert
|
| 252 |
-
Your task is to create
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
CRITICAL: ALL generated images MUST be photorealistic. Always include photorealistic quality descriptors in your prompts."""
|
| 257 |
|
| 258 |
-
if
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
-
|
|
|
|
|
|
|
| 278 |
else:
|
| 279 |
-
#
|
| 280 |
-
prompt_request = f"""
|
|
|
|
|
|
|
| 281 |
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
Composition: {analysis.get('composition', 'Unknown')}
|
| 286 |
-
Subject Matter: {analysis.get('subject_matter', 'Unknown')}
|
| 287 |
-
Target Audience: {analysis.get('target_audience', 'Unknown')}
|
| 288 |
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
| 292 |
|
| 293 |
-
|
| 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(
|
|
@@ -309,29 +340,57 @@ Return ONLY the prompt text, no explanations."""
|
|
| 309 |
temperature=0.7,
|
| 310 |
)
|
| 311 |
|
| 312 |
-
# Clean up
|
| 313 |
prompt = prompt.strip().strip('"').strip("'")
|
| 314 |
|
| 315 |
-
#
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
has_photorealistic = any(keyword.lower() in prompt.lower() for keyword in photorealistic_keywords)
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
if not has_photorealistic:
|
| 320 |
-
#
|
| 321 |
-
prompt = f"
|
| 322 |
-
logger.info("Added
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
return prompt
|
| 327 |
except Exception as e:
|
| 328 |
logger.error(f"Failed to generate modification prompt: {e}")
|
| 329 |
-
# Fallback
|
| 330 |
if mode == "modify":
|
| 331 |
-
return f"
|
| 332 |
else:
|
| 333 |
-
return f"
|
| 334 |
-
|
| 335 |
async def modify_creative(
|
| 336 |
self,
|
| 337 |
image_url: str,
|
|
@@ -391,29 +450,26 @@ Return ONLY the prompt text, no explanations."""
|
|
| 391 |
logger.info("STEP 2: Generating modified image...")
|
| 392 |
try:
|
| 393 |
if mode == "modify":
|
| 394 |
-
#
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
)
|
| 405 |
else:
|
| 406 |
-
#
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
# No image_url - generate fresh
|
| 416 |
-
)
|
| 417 |
|
| 418 |
if not image_bytes:
|
| 419 |
raise Exception("Image generation returned no data")
|
|
|
|
| 248 |
logger.info(f"User angle: {user_angle}")
|
| 249 |
logger.info(f"User concept: {user_concept}")
|
| 250 |
|
| 251 |
+
system_prompt = """You are an expert advertising creative director with 20+ years experience.
|
| 252 |
+
Your task is to create seamless, organic modifications that enhance existing creatives without appearing forced.
|
| 253 |
+
You understand that effective ads feel authentic and natural, not like concepts were "pasted on."
|
| 254 |
+
Focus on subtlety, consistency, and maintaining the original's visual language while applying new psychological angles.
|
| 255 |
+
CRITICAL: ALL generated images MUST be photorealistic. Never mention "AI-generated" or similar terms."""
|
|
|
|
| 256 |
|
| 257 |
+
# Try to enrich angle/concept with library data if not already detailed
|
| 258 |
+
angle_info = user_angle
|
| 259 |
+
concept_info = user_concept
|
| 260 |
+
|
| 261 |
+
try:
|
| 262 |
+
from data.angles import get_all_angles
|
| 263 |
+
from data.concepts import get_all_concepts
|
| 264 |
+
|
| 265 |
+
# Lookup angle details if it's just a name
|
| 266 |
+
if user_angle and "(" not in user_angle:
|
| 267 |
+
all_angles = get_all_angles()
|
| 268 |
+
for a in all_angles:
|
| 269 |
+
if a["name"].lower() == user_angle.lower():
|
| 270 |
+
angle_info = f"{a['name']} (Psychological trigger: {a['trigger']})"
|
| 271 |
+
break
|
| 272 |
+
|
| 273 |
+
# Lookup concept details if it's just a name
|
| 274 |
+
if user_concept and "(" not in user_concept:
|
| 275 |
+
all_concepts = get_all_concepts()
|
| 276 |
+
for c in all_concepts:
|
| 277 |
+
if c["name"].lower() == user_concept.lower():
|
| 278 |
+
concept_info = f"{c['name']} (Visual structure: {c['structure']})"
|
| 279 |
+
break
|
| 280 |
+
except Exception as e:
|
| 281 |
+
logger.warning(f"Failed to enrich angle/concept data: {e}")
|
| 282 |
|
| 283 |
+
# Extract original creative's essence for natural integration
|
| 284 |
+
original_mood = analysis.get('mood', 'Unknown')
|
| 285 |
+
original_style = analysis.get('visual_style', 'Unknown')
|
| 286 |
+
color_palette = analysis.get('color_palette', [])
|
| 287 |
+
subject_matter = analysis.get('subject_matter', 'Unknown')
|
| 288 |
+
composition = analysis.get('composition', 'Unknown')
|
| 289 |
+
|
| 290 |
+
# Guard against common hallucinations (e.g. "fan")
|
| 291 |
+
if "fan of" in subject_matter.lower() and "bills" in subject_matter.lower():
|
| 292 |
+
subject_matter = subject_matter.replace("fan of", "fanned-out stack of").replace("bills", "money/dollar bills")
|
| 293 |
|
| 294 |
+
if mode == "modify":
|
| 295 |
+
# For image-to-image: visually clear modifications
|
| 296 |
+
prompt_request = f"""ORIGINAL IMAGE CONTEXT:
|
| 297 |
+
- Subject: {subject_matter}
|
| 298 |
+
- Mood: {original_mood}
|
| 299 |
+
- Style: {original_style}
|
| 300 |
+
- Composition: {composition}
|
| 301 |
+
- Colors: {', '.join(color_palette[:3]) if color_palette else 'Natural tones'}
|
| 302 |
|
| 303 |
+
TRANSFORMATION TASK:
|
| 304 |
+
- Apply Angle: {angle_info or 'natural enhancement'}
|
| 305 |
+
- Apply Concept: {concept_info or 'subtle adjustment'}
|
| 306 |
|
| 307 |
+
Generate a clear, descriptive transformation prompt (20-40 words) that:
|
| 308 |
+
1. Specifically describes the VISUAL change needed to reflect the new angle and concept.
|
| 309 |
+
2. Makes the transformation feel like a professional edit of the original - NOT a different image.
|
| 310 |
+
3. Keeps the core subjects ({subject_matter}) but adapts their presentation, lighting, or surrounding elements.
|
| 311 |
+
4. Maintain the {original_style} style and {original_mood} tone.
|
| 312 |
+
5. Be explicit about what to change (e.g., "Change the lighting to be more dramatic", "Rearrange elements for [concept]")
|
| 313 |
|
| 314 |
+
CRITICAL: The instruction must be strong enough for an image-to-image AI to actually make a visible change.
|
| 315 |
+
|
| 316 |
+
Return ONLY the prompt text, no explanations."""
|
| 317 |
else:
|
| 318 |
+
# For inspired generation: create new but keep the same authentic vibe
|
| 319 |
+
prompt_request = f"""ORIGINAL CAMPAIGN INSPIRATION:
|
| 320 |
+
- Core Vibe: {original_mood} mood, {original_style} aesthetic
|
| 321 |
+
- Visual Language: {composition}, {subject_matter}
|
| 322 |
|
| 323 |
+
NEW AD CREATIVE REQUIREMENTS:
|
| 324 |
+
- Target Angle: {angle_info or 'natural persuasion'}
|
| 325 |
+
- Target Concept: {concept_info or 'authentic presentation'}
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
+
Generate a premium, authentic advertising photography prompt (60-90 words) that:
|
| 328 |
+
1. Creates a NEW photo that looks like it belongs in the same campaign as the original.
|
| 329 |
+
2. Centers the visual around {concept_info or 'the concept'}.
|
| 330 |
+
3. Conveys the {angle_info or 'the angle'} through unposed, authentic human moments and natural lighting.
|
| 331 |
+
4. Maintains high photorealistic quality with realistic textures and real-world lighting.
|
| 332 |
+
5. Do NOT describe a generic stock photo; describe a high-end, cinematic brand image.
|
| 333 |
|
| 334 |
+
Return ONLY the prompt text, no explanations."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
try:
|
| 337 |
prompt = await llm_service.generate(
|
|
|
|
| 340 |
temperature=0.7,
|
| 341 |
)
|
| 342 |
|
| 343 |
+
# Clean up and ensure natural language
|
| 344 |
prompt = prompt.strip().strip('"').strip("'")
|
| 345 |
|
| 346 |
+
# Enhance for seamlessness if needed
|
| 347 |
+
seamless_keywords = ["seamless", "organic", "natural", "authentic", "integrated"]
|
| 348 |
+
has_seamless = any(keyword in prompt.lower() for keyword in seamless_keywords)
|
| 349 |
+
|
| 350 |
+
photorealistic_keywords = [
|
| 351 |
+
"photorealistic", "realistic photograph", "cinematic photography",
|
| 352 |
+
"authentic photo", "natural lighting", "real-world"
|
| 353 |
+
]
|
| 354 |
has_photorealistic = any(keyword.lower() in prompt.lower() for keyword in photorealistic_keywords)
|
| 355 |
|
| 356 |
+
# Add subtle guidance if missing key elements
|
| 357 |
+
if not has_seamless and mode == "modify":
|
| 358 |
+
prompt = f"Seamlessly integrated, {prompt}"
|
| 359 |
+
|
| 360 |
if not has_photorealistic:
|
| 361 |
+
# Add natural photography terms
|
| 362 |
+
prompt = f"Cinematic photography, authentic moment, natural lighting. {prompt}"
|
| 363 |
+
logger.info("Added natural photography emphasis to prompt")
|
| 364 |
+
|
| 365 |
+
# Remove any phrases that sound artificial or forced
|
| 366 |
+
forced_phrases = [
|
| 367 |
+
"add", "insert", "place", "include the concept of",
|
| 368 |
+
"visibly show", "clearly demonstrate", "obviously"
|
| 369 |
+
]
|
| 370 |
|
| 371 |
+
for phrase in forced_phrases:
|
| 372 |
+
if phrase in prompt.lower():
|
| 373 |
+
# Replace with more natural alternatives
|
| 374 |
+
if phrase == "add":
|
| 375 |
+
prompt = prompt.lower().replace("add", "naturally incorporate")
|
| 376 |
+
elif phrase == "insert":
|
| 377 |
+
prompt = prompt.lower().replace("insert", "subtly integrate")
|
| 378 |
+
elif phrase == "place":
|
| 379 |
+
prompt = prompt.lower().replace("place", "position naturally")
|
| 380 |
+
elif "visibly show" in prompt.lower():
|
| 381 |
+
prompt = prompt.lower().replace("visibly show", "suggest through")
|
| 382 |
+
|
| 383 |
+
logger.info(f"Generated natural prompt: {prompt[:100]}...")
|
| 384 |
|
| 385 |
return prompt
|
| 386 |
except Exception as e:
|
| 387 |
logger.error(f"Failed to generate modification prompt: {e}")
|
| 388 |
+
# Fallback prompts with emphasis on natural integration
|
| 389 |
if mode == "modify":
|
| 390 |
+
return f"Cinematic photography, seamless integration. Subtly enhance {subject_matter} to naturally incorporate {user_angle or 'emotional resonance'} through {user_concept or 'visual storytelling'}. Authentic moment, natural lighting, feels like original campaign."
|
| 391 |
else:
|
| 392 |
+
return f"Cinematic advertising photograph, authentic human moment. {subject_matter} presented with {original_mood} emotional tone, naturally integrating {user_angle or 'persuasive angle'} through {user_concept or 'visual concept'}. Real-world lighting, natural skin textures, campaign-consistent styling."
|
| 393 |
+
|
| 394 |
async def modify_creative(
|
| 395 |
self,
|
| 396 |
image_url: str,
|
|
|
|
| 450 |
logger.info("STEP 2: Generating modified image...")
|
| 451 |
try:
|
| 452 |
if mode == "modify":
|
| 453 |
+
# For subtle modifications with image-to-image
|
| 454 |
+
# Note: nano-banana models don't support guidance_scale or strength parameters
|
| 455 |
+
# The model will naturally preserve the original image based on the prompt
|
| 456 |
+
generation_params = {
|
| 457 |
+
"prompt": prompt,
|
| 458 |
+
"model_key": image_model or "nano-banana",
|
| 459 |
+
"width": width,
|
| 460 |
+
"height": height,
|
| 461 |
+
"image_url": image_url,
|
| 462 |
+
}
|
|
|
|
| 463 |
else:
|
| 464 |
+
# For inspired generation (text-to-image)
|
| 465 |
+
generation_params = {
|
| 466 |
+
"prompt": prompt,
|
| 467 |
+
"model_key": image_model or "nano-banana",
|
| 468 |
+
"width": width,
|
| 469 |
+
"height": height,
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
image_bytes, model_used, generated_url = await image_service.generate(**generation_params)
|
|
|
|
|
|
|
| 473 |
|
| 474 |
if not image_bytes:
|
| 475 |
raise Exception("Image generation returned no data")
|