sushilideaclan01 commited on
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 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">&quot;{analysis.text_content}&quot;</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()