sushilideaclan01 commited on
Commit
ff98a02
Β·
1 Parent(s): 26e8ad9

Enhance image regeneration process with preview and selection confirmation

Browse files

- Added a preview_only option to the image regeneration API, allowing users to generate images without immediate database updates.
- Implemented a new ImageSelectionRequest model for confirming user selection between original and regenerated images.
- Updated the frontend RegenerationModal to include a comparison step, enabling users to choose their preferred image before saving.
- Enhanced API endpoints and types to support the new selection confirmation feature, ensuring a seamless user experience.

frontend/components/generation/RegenerationModal.tsx CHANGED
@@ -1,8 +1,8 @@
1
  "use client";
2
 
3
  import React, { useState, useEffect } from "react";
4
- import { X, RefreshCw, CheckCircle2, AlertCircle, Loader2, Sparkles, ChevronDown } from "lucide-react";
5
- import { regenerateImage, getImageModels } from "@/lib/api/endpoints";
6
  import type { ImageRegenerateResponse, AdCreativeDB, ImageModel } from "@/types/api";
7
  import { ProgressBar } from "@/components/ui/ProgressBar";
8
  import { Button } from "@/components/ui/Button";
@@ -16,7 +16,7 @@ interface RegenerationModalProps {
16
  onSuccess?: (result: ImageRegenerateResponse) => void;
17
  }
18
 
19
- type RegenerationStep = "idle" | "input" | "regenerating" | "complete" | "error";
20
 
21
  export const RegenerationModal: React.FC<RegenerationModalProps> = ({
22
  isOpen,
@@ -33,6 +33,8 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
33
  const [models, setModels] = useState<ImageModel[]>([]);
34
  const [defaultModel, setDefaultModel] = useState<string>("");
35
  const [loadingModels, setLoadingModels] = useState(false);
 
 
36
 
37
  useEffect(() => {
38
  if (isOpen) {
@@ -40,6 +42,7 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
40
  setProgress(0);
41
  setResult(null);
42
  setError(null);
 
43
  // Load available models
44
  loadModels();
45
  } else {
@@ -49,6 +52,7 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
49
  setResult(null);
50
  setError(null);
51
  setSelectedModel(null);
 
52
  }
53
  }, [isOpen]);
54
 
@@ -85,6 +89,7 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
85
  setProgress(0);
86
  setError(null);
87
  setResult(null);
 
88
 
89
  try {
90
  // Simulate progress updates
@@ -97,18 +102,19 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
97
  });
98
  }, 400);
99
 
100
- // Actually perform the regeneration
101
  const response = await regenerateImage({
102
  image_id: adId,
103
  image_model: selectedModel,
 
104
  });
105
 
106
  clearInterval(progressInterval);
107
  setProgress(100);
108
 
109
  if (response.status === "success") {
110
- setStep("complete");
111
  setResult(response);
 
112
  } else {
113
  setStep("error");
114
  setError(response.error || "Regeneration failed");
@@ -120,29 +126,38 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
120
  }
121
  };
122
 
123
- const getStepLabel = () => {
124
- switch (step) {
125
- case "input":
126
- return "Select Model";
127
- case "regenerating":
128
- return "Regenerating image...";
129
- case "complete":
130
- return "Regeneration complete!";
131
- case "error":
132
- return "Error occurred";
133
- default:
134
- return "Starting regeneration...";
135
- }
136
- };
 
 
 
 
 
 
 
 
 
 
 
137
 
138
- const getStepIcon = () => {
139
- switch (step) {
140
- case "complete":
141
- return <CheckCircle2 className="h-6 w-6 text-green-500" />;
142
- case "error":
143
- return <AlertCircle className="h-6 w-6 text-red-500" />;
144
- default:
145
- return <Loader2 className="h-6 w-6 text-blue-500 animate-spin" />;
146
  }
147
  };
148
 
@@ -166,7 +181,7 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
166
 
167
  return (
168
  <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
169
- <div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
170
  {/* Header */}
171
  <div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 rounded-t-2xl z-10">
172
  <div className="flex items-center justify-between">
@@ -177,9 +192,12 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
177
  <div>
178
  <h2 className="text-xl font-bold text-gray-900">Regenerate Image</h2>
179
  <p className="text-sm text-gray-500">
180
- {step === "input"
181
- ? "Generate a new version of your image"
182
- : "Creating a new image with the same prompt"}
 
 
 
183
  </p>
184
  </div>
185
  </div>
@@ -247,7 +265,7 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
247
  <ul className="list-disc list-inside space-y-1 text-purple-700">
248
  <li>Uses the same prompt as the original image</li>
249
  <li>Generates a completely new image with a fresh seed</li>
250
- <li>Original image info is preserved in metadata</li>
251
  </ul>
252
  </div>
253
  </div>
@@ -260,9 +278,9 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
260
  {step === "regenerating" && (
261
  <div className="space-y-4">
262
  <div className="flex items-center gap-4">
263
- {getStepIcon()}
264
  <div className="flex-1">
265
- <p className="font-semibold text-gray-900">{getStepLabel()}</p>
266
  <p className="text-sm text-gray-500 mb-2">Using {getModelDisplayName(selectedModel || defaultModel)}</p>
267
  <ProgressBar progress={progress} showPercentage={true} className="mt-2" />
268
  </div>
@@ -270,13 +288,113 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
270
  </div>
271
  )}
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  {/* Error State */}
274
  {step === "error" && (
275
  <div className="bg-red-50 border border-red-200 rounded-xl p-4">
276
  <div className="flex items-start gap-3">
277
  <AlertCircle className="h-5 w-5 text-red-500 mt-0.5" />
278
  <div className="flex-1">
279
- <h3 className="font-semibold text-red-900 mb-1">Regeneration Failed</h3>
280
  <p className="text-sm text-red-700">{error}</p>
281
  </div>
282
  </div>
@@ -284,78 +402,115 @@ export const RegenerationModal: React.FC<RegenerationModalProps> = ({
284
  )}
285
 
286
  {/* Success State */}
287
- {step === "complete" && result && (
288
  <div className="space-y-4">
289
  <div className="bg-green-50 border border-green-200 rounded-xl p-4">
290
  <div className="flex items-center gap-3">
291
- <CheckCircle2 className="h-5 w-5 text-green-500" />
292
  <div>
293
- <h3 className="font-semibold text-green-900">Regeneration Complete!</h3>
294
- <p className="text-sm text-green-700">Your image has been regenerated successfully</p>
 
 
 
 
295
  </div>
296
  </div>
297
  </div>
298
 
299
- {result.regenerated_image?.image_url && (
300
- <div className="space-y-3">
301
- <h3 className="font-semibold text-gray-900">New Image</h3>
302
  <img
303
- src={result.regenerated_image.image_url}
304
- alt="Regenerated"
305
- className="w-full rounded-lg border border-gray-200"
 
 
 
 
306
  />
307
- </div>
308
- )}
309
-
310
- {/* Show model used */}
311
- {result.regenerated_image?.model_used && (
312
- <div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
313
- <p className="text-xs text-gray-500 mb-1">Model Used</p>
314
- <p className="font-medium text-gray-900">
315
- {getModelDisplayName(result.regenerated_image.model_used)}
316
  </p>
317
  </div>
318
- )}
319
-
320
- {result.original_preserved && (
321
- <div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-800">
322
- <p>The original image info has been preserved in the ad metadata. You can view it from the ad detail page.</p>
323
- </div>
324
- )}
325
  </div>
326
  )}
327
  </div>
328
 
329
  {/* Footer */}
330
  <div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-6 py-4 rounded-b-2xl">
331
- <div className="flex justify-end gap-3">
332
- {step === "input" && (
333
- <Button
334
- onClick={handleRegenerate}
335
- variant="primary"
336
- disabled={!selectedModel || loadingModels}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  >
338
- <Sparkles className="h-4 w-4 mr-2" />
339
- Regenerate Image
340
  </Button>
341
- )}
342
- {step === "error" && (
343
- <Button onClick={() => setStep("input")} variant="primary">
344
- Try Again
345
- </Button>
346
- )}
347
- <Button
348
- onClick={() => {
349
- if (step === "complete" && result) {
350
- // Call onSuccess when user clicks "Done" to reload the ad
351
- onSuccess?.(result);
352
- }
353
- onClose();
354
- }}
355
- variant={step === "complete" ? "primary" : "secondary"}
356
- >
357
- {step === "complete" ? "Done" : "Close"}
358
- </Button>
359
  </div>
360
  </div>
361
  </div>
 
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";
8
  import { Button } from "@/components/ui/Button";
 
16
  onSuccess?: (result: ImageRegenerateResponse) => void;
17
  }
18
 
19
+ type RegenerationStep = "idle" | "input" | "regenerating" | "compare" | "saving" | "complete" | "error";
20
 
21
  export const RegenerationModal: React.FC<RegenerationModalProps> = ({
22
  isOpen,
 
33
  const [models, setModels] = useState<ImageModel[]>([]);
34
  const [defaultModel, setDefaultModel] = useState<string>("");
35
  const [loadingModels, setLoadingModels] = useState(false);
36
+ const [selectedImage, setSelectedImage] = useState<"original" | "new" | null>(null);
37
+ const [savingSelection, setSavingSelection] = useState(false);
38
 
39
  useEffect(() => {
40
  if (isOpen) {
 
42
  setProgress(0);
43
  setResult(null);
44
  setError(null);
45
+ setSelectedImage(null);
46
  // Load available models
47
  loadModels();
48
  } else {
 
52
  setResult(null);
53
  setError(null);
54
  setSelectedModel(null);
55
+ setSelectedImage(null);
56
  }
57
  }, [isOpen]);
58
 
 
89
  setProgress(0);
90
  setError(null);
91
  setResult(null);
92
+ setSelectedImage(null);
93
 
94
  try {
95
  // Simulate progress updates
 
102
  });
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,
110
  });
111
 
112
  clearInterval(progressInterval);
113
  setProgress(100);
114
 
115
  if (response.status === "success") {
 
116
  setResult(response);
117
+ setStep("compare"); // Go to comparison step
118
  } else {
119
  setStep("error");
120
  setError(response.error || "Regeneration failed");
 
126
  }
127
  };
128
 
129
+ const handleConfirmSelection = async () => {
130
+ if (!selectedImage || !result) return;
131
+
132
+ setSavingSelection(true);
133
+ setStep("saving");
134
+
135
+ try {
136
+ if (selectedImage === "new") {
137
+ // Save the new image
138
+ await confirmImageSelection({
139
+ image_id: adId,
140
+ selection: "new",
141
+ new_image_url: result.regenerated_image?.image_url,
142
+ new_r2_url: result.regenerated_image?.r2_url,
143
+ new_filename: result.regenerated_image?.filename,
144
+ new_model: result.regenerated_image?.model_used,
145
+ new_seed: result.regenerated_image?.seed_used,
146
+ });
147
+ } else {
148
+ // Keep the original
149
+ await confirmImageSelection({
150
+ image_id: adId,
151
+ selection: "original",
152
+ });
153
+ }
154
 
155
+ setStep("complete");
156
+ } catch (err: any) {
157
+ setStep("error");
158
+ setError(err.response?.data?.detail || err.message || "Failed to save selection");
159
+ } finally {
160
+ setSavingSelection(false);
 
 
161
  }
162
  };
163
 
 
181
 
182
  return (
183
  <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
184
+ <div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
185
  {/* Header */}
186
  <div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 rounded-t-2xl z-10">
187
  <div className="flex items-center justify-between">
 
192
  <div>
193
  <h2 className="text-xl font-bold text-gray-900">Regenerate Image</h2>
194
  <p className="text-sm text-gray-500">
195
+ {step === "input" && "Generate a new version of your image"}
196
+ {step === "regenerating" && "Creating a new image..."}
197
+ {step === "compare" && "Compare and choose your preferred image"}
198
+ {step === "saving" && "Saving your selection..."}
199
+ {step === "complete" && "Selection saved!"}
200
+ {step === "error" && "Something went wrong"}
201
  </p>
202
  </div>
203
  </div>
 
265
  <ul className="list-disc list-inside space-y-1 text-purple-700">
266
  <li>Uses the same prompt as the original image</li>
267
  <li>Generates a completely new image with a fresh seed</li>
268
+ <li>You can compare both and choose which one to keep</li>
269
  </ul>
270
  </div>
271
  </div>
 
278
  {step === "regenerating" && (
279
  <div className="space-y-4">
280
  <div className="flex items-center gap-4">
281
+ <Loader2 className="h-6 w-6 text-blue-500 animate-spin" />
282
  <div className="flex-1">
283
+ <p className="font-semibold text-gray-900">Generating new image...</p>
284
  <p className="text-sm text-gray-500 mb-2">Using {getModelDisplayName(selectedModel || defaultModel)}</p>
285
  <ProgressBar progress={progress} showPercentage={true} className="mt-2" />
286
  </div>
 
288
  </div>
289
  )}
290
 
291
+ {/* Comparison Step */}
292
+ {step === "compare" && result && (
293
+ <div className="space-y-6">
294
+ <div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
295
+ <p className="text-sm text-blue-800 font-medium">
296
+ Click on the image you want to keep. Your selection will be saved when you confirm.
297
+ </p>
298
+ </div>
299
+
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
+ selectedImage === "original"
306
+ ? "border-green-500 ring-4 ring-green-200"
307
+ : "border-gray-200 hover:border-gray-400"
308
+ }`}
309
+ onClick={() => setSelectedImage("original")}
310
+ >
311
+ <div className="absolute top-3 left-3 z-10">
312
+ <span className="bg-gray-900/80 text-white text-xs font-semibold px-3 py-1.5 rounded-full">
313
+ Original
314
+ </span>
315
+ </div>
316
+ {selectedImage === "original" && (
317
+ <div className="absolute top-3 right-3 z-10">
318
+ <div className="bg-green-500 text-white p-1.5 rounded-full">
319
+ <Check className="h-4 w-4" />
320
+ </div>
321
+ </div>
322
+ )}
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
+ <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">
331
+ {getModelDisplayName(ad?.image_model || "Unknown")}
332
+ </p>
333
+ </div>
334
+ </div>
335
+
336
+ {/* New Image */}
337
+ <div
338
+ className={`relative cursor-pointer rounded-xl overflow-hidden border-4 transition-all ${
339
+ selectedImage === "new"
340
+ ? "border-green-500 ring-4 ring-green-200"
341
+ : "border-gray-200 hover:border-gray-400"
342
+ }`}
343
+ onClick={() => setSelectedImage("new")}
344
+ >
345
+ <div className="absolute top-3 left-3 z-10">
346
+ <span className="bg-purple-600 text-white text-xs font-semibold px-3 py-1.5 rounded-full">
347
+ New
348
+ </span>
349
+ </div>
350
+ {selectedImage === "new" && (
351
+ <div className="absolute top-3 right-3 z-10">
352
+ <div className="bg-green-500 text-white p-1.5 rounded-full">
353
+ <Check className="h-4 w-4" />
354
+ </div>
355
+ </div>
356
+ )}
357
+ <img
358
+ src={result.regenerated_image?.image_url || ""}
359
+ alt="Regenerated"
360
+ className="w-full aspect-square object-cover"
361
+ />
362
+ <div className="p-3 bg-purple-50">
363
+ <p className="text-xs text-gray-500">Model</p>
364
+ <p className="text-sm font-medium text-gray-900">
365
+ {getModelDisplayName(result.regenerated_image?.model_used || "Unknown")}
366
+ </p>
367
+ </div>
368
+ </div>
369
+ </div>
370
+
371
+ {/* Selection hint */}
372
+ {!selectedImage && (
373
+ <p className="text-center text-sm text-gray-500">
374
+ Click on an image to select it
375
+ </p>
376
+ )}
377
+ </div>
378
+ )}
379
+
380
+ {/* Saving Step */}
381
+ {step === "saving" && (
382
+ <div className="flex flex-col items-center justify-center py-8">
383
+ <Loader2 className="h-10 w-10 text-purple-500 animate-spin mb-4" />
384
+ <p className="text-lg font-semibold text-gray-900">Saving your selection...</p>
385
+ <p className="text-sm text-gray-500">
386
+ {selectedImage === "new" ? "Updating to new image" : "Keeping original image"}
387
+ </p>
388
+ </div>
389
+ )}
390
+
391
  {/* Error State */}
392
  {step === "error" && (
393
  <div className="bg-red-50 border border-red-200 rounded-xl p-4">
394
  <div className="flex items-start gap-3">
395
  <AlertCircle className="h-5 w-5 text-red-500 mt-0.5" />
396
  <div className="flex-1">
397
+ <h3 className="font-semibold text-red-900 mb-1">Something went wrong</h3>
398
  <p className="text-sm text-red-700">{error}</p>
399
  </div>
400
  </div>
 
402
  )}
403
 
404
  {/* Success State */}
405
+ {step === "complete" && (
406
  <div className="space-y-4">
407
  <div className="bg-green-50 border border-green-200 rounded-xl p-4">
408
  <div className="flex items-center gap-3">
409
+ <CheckCircle2 className="h-6 w-6 text-green-500" />
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>
418
  </div>
419
  </div>
420
 
421
+ {/* Show the selected image */}
422
+ <div className="flex justify-center">
423
+ <div className="max-w-md">
424
  <img
425
+ src={
426
+ selectedImage === "new"
427
+ ? (result?.regenerated_image?.image_url || "")
428
+ : (result?.original_image_url || ad?.r2_url || ad?.image_url || "")
429
+ }
430
+ alt="Selected"
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>
436
  </div>
437
+ </div>
 
 
 
 
 
 
438
  </div>
439
  )}
440
  </div>
441
 
442
  {/* Footer */}
443
  <div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-6 py-4 rounded-b-2xl">
444
+ <div className="flex justify-between gap-3">
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" />
457
+ Generate Another
458
+ </Button>
459
+ )}
460
+ </div>
461
+
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
+ >
470
+ <Sparkles className="h-4 w-4 mr-2" />
471
+ Regenerate Image
472
+ </Button>
473
+ )}
474
+
475
+ {step === "compare" && (
476
+ <Button
477
+ onClick={handleConfirmSelection}
478
+ variant="primary"
479
+ disabled={!selectedImage || savingSelection}
480
+ >
481
+ {savingSelection ? (
482
+ <>
483
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
484
+ Saving...
485
+ </>
486
+ ) : (
487
+ <>
488
+ <Check className="h-4 w-4 mr-2" />
489
+ Confirm Selection
490
+ </>
491
+ )}
492
+ </Button>
493
+ )}
494
+
495
+ {step === "error" && (
496
+ <Button onClick={() => setStep("input")} variant="primary">
497
+ Try Again
498
+ </Button>
499
+ )}
500
+
501
+ <Button
502
+ onClick={() => {
503
+ if (step === "complete" && result) {
504
+ // Call onSuccess when user clicks "Done" to reload the ad
505
+ onSuccess?.(result);
506
+ }
507
+ onClose();
508
+ }}
509
+ variant={step === "complete" ? "primary" : "secondary"}
510
  >
511
+ {step === "complete" ? "Done" : "Close"}
 
512
  </Button>
513
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  </div>
515
  </div>
516
  </div>
frontend/lib/api/endpoints.ts CHANGED
@@ -181,8 +181,26 @@ export const correctImage = async (params: {
181
  export const regenerateImage = async (params: {
182
  image_id: string;
183
  image_model?: string | null;
 
184
  }): Promise<ImageRegenerateResponse> => {
185
- const response = await apiClient.post<ImageRegenerateResponse>("/api/regenerate", params);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  return response.data;
187
  };
188
 
 
181
  export const regenerateImage = async (params: {
182
  image_id: string;
183
  image_model?: string | null;
184
+ preview_only?: boolean;
185
  }): Promise<ImageRegenerateResponse> => {
186
+ const response = await apiClient.post<ImageRegenerateResponse>("/api/regenerate", {
187
+ ...params,
188
+ preview_only: params.preview_only ?? true, // Default to preview mode
189
+ });
190
+ return response.data;
191
+ };
192
+
193
+ // Confirm Image Selection after Regeneration
194
+ export const confirmImageSelection = async (params: {
195
+ image_id: string;
196
+ selection: "new" | "original";
197
+ new_image_url?: string | null;
198
+ new_r2_url?: string | null;
199
+ new_filename?: string | null;
200
+ new_model?: string | null;
201
+ new_seed?: number | null;
202
+ }): Promise<{ status: string; message: string; selection: string; new_image_url?: string }> => {
203
+ const response = await apiClient.post("/api/regenerate/confirm", params);
204
  return response.data;
205
  };
206
 
frontend/types/api.ts CHANGED
@@ -334,15 +334,35 @@ export interface RegeneratedImageResult {
334
  r2_url?: string | null;
335
  model_used?: string | null;
336
  prompt_used?: string | null;
 
337
  }
338
 
339
  export interface ImageRegenerateResponse {
340
  status: string;
341
  regenerated_image?: RegeneratedImageResult | null;
 
342
  original_preserved: boolean;
 
343
  error?: string | null;
344
  }
345
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  export interface ImageModel {
347
  key: string;
348
  id: string;
 
334
  r2_url?: string | null;
335
  model_used?: string | null;
336
  prompt_used?: string | null;
337
+ seed_used?: number | null;
338
  }
339
 
340
  export interface ImageRegenerateResponse {
341
  status: string;
342
  regenerated_image?: RegeneratedImageResult | null;
343
+ original_image_url?: string | null;
344
  original_preserved: boolean;
345
+ is_preview?: boolean;
346
  error?: string | null;
347
  }
348
 
349
+ export interface ImageSelectionRequest {
350
+ image_id: string;
351
+ selection: "new" | "original";
352
+ new_image_url?: string | null;
353
+ new_r2_url?: string | null;
354
+ new_filename?: string | null;
355
+ new_model?: string | null;
356
+ new_seed?: number | null;
357
+ }
358
+
359
+ export interface ImageSelectionResponse {
360
+ status: string;
361
+ message: string;
362
+ selection: "new" | "original";
363
+ new_image_url?: string | null;
364
+ }
365
+
366
  export interface ImageModel {
367
  key: string;
368
  id: string;
main.py CHANGED
@@ -898,6 +898,10 @@ class ImageRegenerateRequest(BaseModel):
898
  default=None,
899
  description="Image generation model to use (e.g., 'z-image-turbo', 'nano-banana', 'nano-banana-pro', 'imagen-4-ultra', 'recraft-v3', 'ideogram-v3', 'photon', 'seedream-3'). If not provided, uses the original model."
900
  )
 
 
 
 
901
 
902
 
903
  class RegeneratedImageResult(BaseModel):
@@ -908,16 +912,30 @@ class RegeneratedImageResult(BaseModel):
908
  r2_url: Optional[str] = None
909
  model_used: Optional[str] = None
910
  prompt_used: Optional[str] = None
 
911
 
912
 
913
  class ImageRegenerateResponse(BaseModel):
914
  """Response schema for image regeneration."""
915
  status: str
916
  regenerated_image: Optional[RegeneratedImageResult] = None
 
917
  original_preserved: bool = Field(default=True, description="Whether original image info was preserved in metadata")
 
918
  error: Optional[str] = None
919
 
920
 
 
 
 
 
 
 
 
 
 
 
 
921
  @app.post("/api/regenerate", response_model=ImageRegenerateResponse)
922
  async def regenerate_image(
923
  request: ImageRegenerateRequest,
@@ -928,12 +946,13 @@ async def regenerate_image(
928
 
929
  Requires authentication. Users can only regenerate their own ads.
930
 
931
- The service will:
932
- 1. Fetch the original ad and its image prompt from the database
933
- 2. Regenerate the image using the specified model (or original model if not provided)
934
- 3. Upload to R2 storage
935
- 4. Update the ad with the new image, preserving original image info in metadata
936
- 5. Return the regenerated image
 
937
  """
938
  api_start_time = time.time()
939
  api_logger.info("=" * 80)
@@ -941,6 +960,7 @@ async def regenerate_image(
941
  api_logger.info(f"User: {username}")
942
  api_logger.info(f"Image ID: {request.image_id}")
943
  api_logger.info(f"Requested model: {request.image_model or 'Use original'}")
 
944
 
945
  try:
946
  # Fetch ad from database (only if it belongs to current user)
@@ -1017,9 +1037,36 @@ async def regenerate_image(
1017
  f.write(image_bytes)
1018
  api_logger.info(f"Saved locally: {local_path}")
1019
 
1020
- # Prepare to update the ad
1021
- # Store old image data in metadata before updating
1022
- old_image_url = ad.get("r2_url") or ad.get("image_url")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1023
  old_r2_url = ad.get("r2_url")
1024
  old_image_filename = ad.get("image_filename")
1025
  old_image_model = ad.get("image_model")
@@ -1032,8 +1079,8 @@ async def regenerate_image(
1032
  "regeneration_seed": seed,
1033
  }
1034
 
1035
- if old_image_url:
1036
- regeneration_metadata["original_image_url"] = old_image_url
1037
  if old_r2_url:
1038
  regeneration_metadata["original_r2_url"] = old_r2_url
1039
  if old_image_filename:
@@ -1080,12 +1127,15 @@ async def regenerate_image(
1080
  "regenerated_image": {
1081
  "filename": filename,
1082
  "filepath": local_path,
1083
- "image_url": r2_url or generated_url or f"/images/{filename}",
1084
  "r2_url": r2_url,
1085
  "model_used": model_used,
1086
  "prompt_used": image_prompt,
 
1087
  },
 
1088
  "original_preserved": True,
 
1089
  }
1090
 
1091
  except HTTPException:
@@ -1099,6 +1149,126 @@ async def regenerate_image(
1099
  raise HTTPException(status_code=500, detail=str(e))
1100
 
1101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1102
  @app.get("/api/models")
1103
  async def list_image_models():
1104
  """
 
898
  default=None,
899
  description="Image generation model to use (e.g., 'z-image-turbo', 'nano-banana', 'nano-banana-pro', 'imagen-4-ultra', 'recraft-v3', 'ideogram-v3', 'photon', 'seedream-3'). If not provided, uses the original model."
900
  )
901
+ preview_only: bool = Field(
902
+ default=True,
903
+ description="If True, generates preview without updating database. User can then confirm selection."
904
+ )
905
 
906
 
907
  class RegeneratedImageResult(BaseModel):
 
912
  r2_url: Optional[str] = None
913
  model_used: Optional[str] = None
914
  prompt_used: Optional[str] = None
915
+ seed_used: Optional[int] = None
916
 
917
 
918
  class ImageRegenerateResponse(BaseModel):
919
  """Response schema for image regeneration."""
920
  status: str
921
  regenerated_image: Optional[RegeneratedImageResult] = None
922
+ original_image_url: Optional[str] = None
923
  original_preserved: bool = Field(default=True, description="Whether original image info was preserved in metadata")
924
+ is_preview: bool = Field(default=False, description="Whether this is a preview (not yet saved)")
925
  error: Optional[str] = None
926
 
927
 
928
+ class ImageSelectionRequest(BaseModel):
929
+ """Request schema for confirming image selection after regeneration."""
930
+ image_id: str = Field(description="ID of existing ad creative in database")
931
+ selection: str = Field(description="Which image to keep: 'new' or 'original'")
932
+ new_image_url: Optional[str] = Field(default=None, description="URL of the new image (required if selection='new')")
933
+ new_r2_url: Optional[str] = Field(default=None, description="R2 URL of the new image")
934
+ new_filename: Optional[str] = Field(default=None, description="Filename of the new image")
935
+ new_model: Optional[str] = Field(default=None, description="Model used for the new image")
936
+ new_seed: Optional[int] = Field(default=None, description="Seed used for the new image")
937
+
938
+
939
  @app.post("/api/regenerate", response_model=ImageRegenerateResponse)
940
  async def regenerate_image(
941
  request: ImageRegenerateRequest,
 
946
 
947
  Requires authentication. Users can only regenerate their own ads.
948
 
949
+ If preview_only=True (default):
950
+ - Generates a new image and uploads to storage
951
+ - Returns both old and new image URLs for comparison
952
+ - Does NOT update the database yet
953
+
954
+ If preview_only=False:
955
+ - Generates and immediately saves to database (legacy behavior)
956
  """
957
  api_start_time = time.time()
958
  api_logger.info("=" * 80)
 
960
  api_logger.info(f"User: {username}")
961
  api_logger.info(f"Image ID: {request.image_id}")
962
  api_logger.info(f"Requested model: {request.image_model or 'Use original'}")
963
+ api_logger.info(f"Preview only: {request.preview_only}")
964
 
965
  try:
966
  # Fetch ad from database (only if it belongs to current user)
 
1037
  f.write(image_bytes)
1038
  api_logger.info(f"Saved locally: {local_path}")
1039
 
1040
+ # Get original image URL for comparison
1041
+ original_image_url = ad.get("r2_url") or ad.get("image_url")
1042
+
1043
+ # Determine the new image URL
1044
+ new_image_url = r2_url or generated_url or f"/images/{filename}"
1045
+
1046
+ # If preview_only, return without updating database
1047
+ if request.preview_only:
1048
+ total_api_time = time.time() - api_start_time
1049
+ api_logger.info("=" * 80)
1050
+ api_logger.info(f"βœ“ API: Regeneration preview completed in {total_api_time:.2f}s (not saved to DB)")
1051
+ api_logger.info("=" * 80)
1052
+
1053
+ return {
1054
+ "status": "success",
1055
+ "regenerated_image": {
1056
+ "filename": filename,
1057
+ "filepath": local_path,
1058
+ "image_url": new_image_url,
1059
+ "r2_url": r2_url,
1060
+ "model_used": model_used,
1061
+ "prompt_used": image_prompt,
1062
+ "seed_used": seed,
1063
+ },
1064
+ "original_image_url": original_image_url,
1065
+ "original_preserved": True,
1066
+ "is_preview": True,
1067
+ }
1068
+
1069
+ # If not preview_only, update the database immediately (legacy behavior)
1070
  old_r2_url = ad.get("r2_url")
1071
  old_image_filename = ad.get("image_filename")
1072
  old_image_model = ad.get("image_model")
 
1079
  "regeneration_seed": seed,
1080
  }
1081
 
1082
+ if original_image_url:
1083
+ regeneration_metadata["original_image_url"] = original_image_url
1084
  if old_r2_url:
1085
  regeneration_metadata["original_r2_url"] = old_r2_url
1086
  if old_image_filename:
 
1127
  "regenerated_image": {
1128
  "filename": filename,
1129
  "filepath": local_path,
1130
+ "image_url": new_image_url,
1131
  "r2_url": r2_url,
1132
  "model_used": model_used,
1133
  "prompt_used": image_prompt,
1134
+ "seed_used": seed,
1135
  },
1136
+ "original_image_url": original_image_url,
1137
  "original_preserved": True,
1138
+ "is_preview": False,
1139
  }
1140
 
1141
  except HTTPException:
 
1149
  raise HTTPException(status_code=500, detail=str(e))
1150
 
1151
 
1152
+ @app.post("/api/regenerate/confirm")
1153
+ async def confirm_image_selection(
1154
+ request: ImageSelectionRequest,
1155
+ username: str = Depends(get_current_user)
1156
+ ):
1157
+ """
1158
+ Confirm the user's image selection after regeneration preview.
1159
+
1160
+ If selection='new': Updates the ad with the new regenerated image
1161
+ If selection='original': Keeps the original image (no database update needed)
1162
+ """
1163
+ api_start_time = time.time()
1164
+ api_logger.info("=" * 80)
1165
+ api_logger.info(f"API: Image selection confirmation received")
1166
+ api_logger.info(f"User: {username}")
1167
+ api_logger.info(f"Image ID: {request.image_id}")
1168
+ api_logger.info(f"Selection: {request.selection}")
1169
+
1170
+ try:
1171
+ # Validate selection value
1172
+ if request.selection not in ["new", "original"]:
1173
+ raise HTTPException(status_code=400, detail="Selection must be 'new' or 'original'")
1174
+
1175
+ # Fetch ad from database (only if it belongs to current user)
1176
+ ad = await db_service.get_ad_creative(request.image_id, username=username)
1177
+ if not ad:
1178
+ api_logger.error(f"Ad creative {request.image_id} not found or access denied for user {username}")
1179
+ raise HTTPException(status_code=404, detail=f"Ad creative with ID {request.image_id} not found or access denied")
1180
+
1181
+ if request.selection == "original":
1182
+ # User chose to keep the original - no database update needed
1183
+ api_logger.info("User chose to keep original image - no update needed")
1184
+ total_api_time = time.time() - api_start_time
1185
+ api_logger.info(f"βœ“ API: Selection confirmed (original kept) in {total_api_time:.2f}s")
1186
+ return {
1187
+ "status": "success",
1188
+ "message": "Original image kept",
1189
+ "selection": "original",
1190
+ }
1191
+
1192
+ # User chose the new image - update the database
1193
+ if not request.new_image_url:
1194
+ raise HTTPException(status_code=400, detail="new_image_url is required when selection='new'")
1195
+
1196
+ # Get original image info before updating
1197
+ original_image_url = ad.get("r2_url") or ad.get("image_url")
1198
+ original_r2_url = ad.get("r2_url")
1199
+ original_filename = ad.get("image_filename")
1200
+ original_model = ad.get("image_model")
1201
+ original_seed = ad.get("image_seed")
1202
+
1203
+ # Build metadata with original image info
1204
+ regeneration_metadata = {
1205
+ "is_regenerated": True,
1206
+ "regeneration_date": datetime.utcnow().isoformat() + "Z",
1207
+ "regeneration_seed": request.new_seed,
1208
+ }
1209
+
1210
+ if original_image_url:
1211
+ regeneration_metadata["original_image_url"] = original_image_url
1212
+ if original_r2_url:
1213
+ regeneration_metadata["original_r2_url"] = original_r2_url
1214
+ if original_filename:
1215
+ regeneration_metadata["original_image_filename"] = original_filename
1216
+ if original_model:
1217
+ regeneration_metadata["original_image_model"] = original_model
1218
+ if original_seed:
1219
+ regeneration_metadata["original_seed"] = original_seed
1220
+
1221
+ # Update the ad with new image
1222
+ update_kwargs = {}
1223
+ if request.new_filename:
1224
+ update_kwargs["image_filename"] = request.new_filename
1225
+ if request.new_model:
1226
+ update_kwargs["image_model"] = request.new_model
1227
+ if request.new_seed:
1228
+ update_kwargs["image_seed"] = request.new_seed
1229
+ if request.new_r2_url:
1230
+ update_kwargs["image_url"] = request.new_r2_url
1231
+ update_kwargs["r2_url"] = request.new_r2_url
1232
+ elif request.new_image_url:
1233
+ update_kwargs["image_url"] = request.new_image_url
1234
+
1235
+ api_logger.info(f"Updating ad {request.image_id} with new image...")
1236
+ update_success = await db_service.update_ad_creative(
1237
+ ad_id=request.image_id,
1238
+ username=username,
1239
+ metadata=regeneration_metadata,
1240
+ **update_kwargs
1241
+ )
1242
+
1243
+ if update_success:
1244
+ api_logger.info(f"βœ“ Ad updated with selected new image (ID: {request.image_id})")
1245
+ else:
1246
+ api_logger.warning("Failed to update ad with new image")
1247
+ raise HTTPException(status_code=500, detail="Failed to update ad with new image")
1248
+
1249
+ total_api_time = time.time() - api_start_time
1250
+ api_logger.info("=" * 80)
1251
+ api_logger.info(f"βœ“ API: Selection confirmed (new image saved) in {total_api_time:.2f}s")
1252
+ api_logger.info("=" * 80)
1253
+
1254
+ return {
1255
+ "status": "success",
1256
+ "message": "New image saved",
1257
+ "selection": "new",
1258
+ "new_image_url": request.new_image_url,
1259
+ }
1260
+
1261
+ except HTTPException:
1262
+ total_api_time = time.time() - api_start_time
1263
+ api_logger.error(f"βœ— API: Selection confirmation failed with HTTPException after {total_api_time:.2f}s")
1264
+ raise
1265
+ except Exception as e:
1266
+ total_api_time = time.time() - api_start_time
1267
+ api_logger.error(f"βœ— API: Selection confirmation failed with exception after {total_api_time:.2f}s: {str(e)}")
1268
+ api_logger.exception("Full exception traceback:")
1269
+ raise HTTPException(status_code=500, detail=str(e))
1270
+
1271
+
1272
  @app.get("/api/models")
1273
  async def list_image_models():
1274
  """