sushilideaclan01 commited on
Commit
d415d18
·
1 Parent(s): d62c328
api/routers/generate.py CHANGED
@@ -76,25 +76,33 @@ async def get_image(filename: str):
76
  return FileResponse(filepath)
77
 
78
 
 
 
 
79
  @router.get("/serve-image")
80
  async def serve_image(filename: Optional[str] = None):
81
  """
82
  Proxy endpoint for generated images: serve from local disk or fetch from R2.
83
  Use this when the client cannot reach R2 (e.g. net::ERR_TUNNEL_CONNECTION_FAILED).
84
  No auth so gallery and public views can load images.
 
85
  """
86
  if not filename or ".." in filename or "/" in filename or "\\" in filename:
87
  raise HTTPException(status_code=400, detail="Invalid filename")
88
  filepath = os.path.join(settings.output_dir, filename)
89
  if os.path.exists(filepath):
90
- return FileResponse(filepath)
91
  try:
92
  from services.r2_storage import get_r2_storage
93
  r2 = get_r2_storage()
94
  if r2:
95
  data = r2.get_object_bytes(filename)
96
  if data:
97
- return FastAPIResponse(content=data, media_type="image/png")
 
 
 
 
98
  except Exception:
99
  pass
100
  raise HTTPException(status_code=404, detail="Image not found")
 
76
  return FileResponse(filepath)
77
 
78
 
79
+ IMAGE_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
80
+
81
+
82
  @router.get("/serve-image")
83
  async def serve_image(filename: Optional[str] = None):
84
  """
85
  Proxy endpoint for generated images: serve from local disk or fetch from R2.
86
  Use this when the client cannot reach R2 (e.g. net::ERR_TUNNEL_CONNECTION_FAILED).
87
  No auth so gallery and public views can load images.
88
+ Responses are cached by the browser for 1 year so repeat visits don't re-download.
89
  """
90
  if not filename or ".." in filename or "/" in filename or "\\" in filename:
91
  raise HTTPException(status_code=400, detail="Invalid filename")
92
  filepath = os.path.join(settings.output_dir, filename)
93
  if os.path.exists(filepath):
94
+ return FileResponse(filepath, headers=IMAGE_CACHE_HEADERS)
95
  try:
96
  from services.r2_storage import get_r2_storage
97
  r2 = get_r2_storage()
98
  if r2:
99
  data = r2.get_object_bytes(filename)
100
  if data:
101
+ return FastAPIResponse(
102
+ content=data,
103
+ media_type="image/png",
104
+ headers=IMAGE_CACHE_HEADERS,
105
+ )
106
  except Exception:
107
  pass
108
  raise HTTPException(status_code=404, detail="Image not found")
frontend/app/page.tsx CHANGED
@@ -8,7 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
8
  import { Button } from "@/components/ui/Button";
9
  import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
10
  import { getDbStats, listAds } from "@/lib/api/endpoints";
11
- import { formatRelativeDate, formatNiche, getImageUrl, getImageUrlFallback } from "@/lib/utils/formatters";
12
  import { Home, Sparkles, Grid, TrendingUp, Database } from "lucide-react";
13
  import { useGalleryStore } from "@/store/galleryStore";
14
  import type { DbStatsResponse, AdCreativeDB } from "@/types/api";
@@ -63,7 +63,7 @@ const DashboardAdCard = memo(function DashboardAdCard({
63
  onError={handleImageError}
64
  loading="lazy"
65
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
66
- unoptimized={imageSrc.startsWith('http://') || imageSrc.includes('r2.cloudflarestorage.com') || imageSrc.includes('replicate.delivery') || imageSrc.includes('/serve-image') || imageSrc.includes('/images/')}
67
  />
68
  ) : (
69
  <div className="w-full h-full flex items-center justify-center">
@@ -283,14 +283,13 @@ export default function Dashboard() {
283
  ) : (
284
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
285
  {recentAds.map((ad, index) => {
286
- const { primary, fallback } = getImageUrlFallback(ad.image_url, ad.image_filename, ad.r2_url);
287
- const initialSrc = primary || fallback;
288
 
289
  return (
290
  <DashboardAdCard
291
  key={ad.id}
292
  ad={ad}
293
- initialImageSrc={initialSrc}
294
  fallback={fallback}
295
  index={index}
296
  />
 
8
  import { Button } from "@/components/ui/Button";
9
  import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
10
  import { getDbStats, listAds } from "@/lib/api/endpoints";
11
+ import { formatRelativeDate, formatNiche, getImageUrlFallback, isUnoptimizedImageUrl } from "@/lib/utils/formatters";
12
  import { Home, Sparkles, Grid, TrendingUp, Database } from "lucide-react";
13
  import { useGalleryStore } from "@/store/galleryStore";
14
  import type { DbStatsResponse, AdCreativeDB } from "@/types/api";
 
63
  onError={handleImageError}
64
  loading="lazy"
65
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
66
+ unoptimized={isUnoptimizedImageUrl(imageSrc)}
67
  />
68
  ) : (
69
  <div className="w-full h-full flex items-center justify-center">
 
283
  ) : (
284
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
285
  {recentAds.map((ad, index) => {
286
+ const { fallback, initialUrl } = getImageUrlFallback(ad.image_url, ad.image_filename, ad.r2_url);
 
287
 
288
  return (
289
  <DashboardAdCard
290
  key={ad.id}
291
  ad={ad}
292
+ initialImageSrc={initialUrl}
293
  fallback={fallback}
294
  index={index}
295
  />
frontend/components/gallery/AdCard.tsx CHANGED
@@ -1,10 +1,10 @@
1
  "use client";
2
 
3
- import React, { useState, memo } from "react";
4
  import Link from "next/link";
5
  import Image from "next/image";
6
  import { Card, CardContent } from "@/components/ui/Card";
7
- import { formatRelativeDate, formatNiche, getImageUrl, getImageUrlFallback, truncateText } from "@/lib/utils/formatters";
8
  import { CheckSquare, Square } from "lucide-react";
9
  import type { AdCreativeDB } from "@/types/api";
10
 
@@ -28,10 +28,15 @@ export const AdCard: React.FC<AdCardProps> = memo(({
28
  onSelect,
29
  hasAnySelection = false,
30
  }) => {
31
- const { primary, fallback } = getImageUrlFallback(ad.image_url, ad.image_filename, ad.r2_url);
32
- const [imageSrc, setImageSrc] = useState<string | null>(primary || fallback);
33
  const [imageError, setImageError] = useState(false);
34
 
 
 
 
 
 
35
  const handleImageError = () => {
36
  // Try fallback if primary failed
37
  if (!imageError && fallback && imageSrc === primary) {
@@ -64,7 +69,7 @@ export const AdCard: React.FC<AdCardProps> = memo(({
64
  onError={handleImageError}
65
  loading="lazy"
66
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
67
- unoptimized={imageSrc.startsWith('http://') || imageSrc.includes('r2.cloudflarestorage.com') || imageSrc.includes('replicate.delivery') || imageSrc.includes('/serve-image') || imageSrc.includes('/images/')}
68
  />
69
  ) : (
70
  <div className="w-full h-full flex items-center justify-center">
@@ -135,12 +140,13 @@ export const AdCard: React.FC<AdCardProps> = memo(({
135
  </Card>
136
  );
137
  }, (prevProps, nextProps) => {
138
- // Only re-render if ad data or selection state changes
139
  return (
140
  prevProps.ad.id === nextProps.ad.id &&
141
  prevProps.isSelected === nextProps.isSelected &&
142
  prevProps.hasAnySelection === nextProps.hasAnySelection &&
143
- prevProps.ad.image_url === nextProps.ad.image_url
 
 
144
  );
145
  });
146
 
 
1
  "use client";
2
 
3
+ import React, { useState, useEffect, memo } from "react";
4
  import Link from "next/link";
5
  import Image from "next/image";
6
  import { Card, CardContent } from "@/components/ui/Card";
7
+ import { formatRelativeDate, formatNiche, getImageUrlFallback, isUnoptimizedImageUrl } from "@/lib/utils/formatters";
8
  import { CheckSquare, Square } from "lucide-react";
9
  import type { AdCreativeDB } from "@/types/api";
10
 
 
28
  onSelect,
29
  hasAnySelection = false,
30
  }) => {
31
+ const { primary, fallback, initialUrl } = getImageUrlFallback(ad.image_url, ad.image_filename, ad.r2_url);
32
+ const [imageSrc, setImageSrc] = useState<string | null>(initialUrl);
33
  const [imageError, setImageError] = useState(false);
34
 
35
+ useEffect(() => {
36
+ setImageSrc(initialUrl);
37
+ setImageError(false);
38
+ }, [ad.id, ad.image_url, ad.image_filename, ad.r2_url, initialUrl]);
39
+
40
  const handleImageError = () => {
41
  // Try fallback if primary failed
42
  if (!imageError && fallback && imageSrc === primary) {
 
69
  onError={handleImageError}
70
  loading="lazy"
71
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
72
+ unoptimized={isUnoptimizedImageUrl(imageSrc)}
73
  />
74
  ) : (
75
  <div className="w-full h-full flex items-center justify-center">
 
140
  </Card>
141
  );
142
  }, (prevProps, nextProps) => {
 
143
  return (
144
  prevProps.ad.id === nextProps.ad.id &&
145
  prevProps.isSelected === nextProps.isSelected &&
146
  prevProps.hasAnySelection === nextProps.hasAnySelection &&
147
+ prevProps.ad.image_url === nextProps.ad.image_url &&
148
+ prevProps.ad.image_filename === nextProps.ad.image_filename &&
149
+ prevProps.ad.r2_url === nextProps.ad.r2_url
150
  );
151
  });
152
 
frontend/lib/utils/formatters.ts CHANGED
@@ -98,6 +98,14 @@ function getApiBase(): string {
98
  const isAbsoluteUrl = (url: string) =>
99
  typeof url === "string" && (url.startsWith("http://") || url.startsWith("https://"));
100
 
 
 
 
 
 
 
 
 
101
  /**
102
  * Resolve display URL for an ad image. Prefers R2 URL when available (CDN/public),
103
  * then absolute image_url, then local /images/{filename} or relative image_url.
@@ -117,13 +125,13 @@ export const getImageUrl = (
117
 
118
  /**
119
  * Primary = R2 URL or absolute image_url (for display). Fallback = backend proxy (serve-image) or local /images.
120
- * Use primary in <img src>, then onError switch to fallback. Proxy fetches from R2 server-side when the client cannot reach R2 (e.g. ERR_TUNNEL_CONNECTION_FAILED).
121
  */
122
  export const getImageUrlFallback = (
123
  imageUrl: string | null | undefined,
124
  filename: string | null | undefined,
125
  r2Url?: string | null
126
- ): { primary: string | null; fallback: string | null } => {
127
  const apiBase = getApiBase();
128
  const primary =
129
  (r2Url && isAbsoluteUrl(r2Url)) ? r2Url
@@ -138,5 +146,8 @@ export const getImageUrlFallback = (
138
  ? `${apiBase}${imageUrl}`
139
  : null;
140
  const fallback = fromProxy ?? fromFilename ?? fromRelative ?? null;
141
- return { primary, fallback };
 
 
 
142
  };
 
98
  const isAbsoluteUrl = (url: string) =>
99
  typeof url === "string" && (url.startsWith("http://") || url.startsWith("https://"));
100
 
101
+ /** Use when passing URL to next/image: skip Next.js optimization for external or backend-proxy URLs. */
102
+ export const isUnoptimizedImageUrl = (url: string): boolean =>
103
+ url.startsWith("http://") ||
104
+ url.includes("r2.cloudflarestorage.com") ||
105
+ url.includes("replicate.delivery") ||
106
+ url.includes("/serve-image") ||
107
+ url.includes("/images/");
108
+
109
  /**
110
  * Resolve display URL for an ad image. Prefers R2 URL when available (CDN/public),
111
  * then absolute image_url, then local /images/{filename} or relative image_url.
 
125
 
126
  /**
127
  * Primary = R2 URL or absolute image_url (for display). Fallback = backend proxy (serve-image) or local /images.
128
+ * initialUrl = URL to try first; on environments where R2 often fails (e.g. HF Space), uses proxy first to avoid double request.
129
  */
130
  export const getImageUrlFallback = (
131
  imageUrl: string | null | undefined,
132
  filename: string | null | undefined,
133
  r2Url?: string | null
134
+ ): { primary: string | null; fallback: string | null; initialUrl: string | null } => {
135
  const apiBase = getApiBase();
136
  const primary =
137
  (r2Url && isAbsoluteUrl(r2Url)) ? r2Url
 
146
  ? `${apiBase}${imageUrl}`
147
  : null;
148
  const fallback = fromProxy ?? fromFilename ?? fromRelative ?? null;
149
+ const proxyFirst =
150
+ typeof window !== "undefined" && window.location.hostname.endsWith(".hf.space");
151
+ const initialUrl = proxyFirst && fromProxy ? fromProxy : (primary || fallback);
152
+ return { primary, fallback, initialUrl };
153
  };