Spaces:
Sleeping
Sleeping
Commit ·
d415d18
1
Parent(s): d62c328
- api/routers/generate.py +10 -2
- frontend/app/page.tsx +4 -5
- frontend/components/gallery/AdCard.tsx +13 -7
- frontend/lib/utils/formatters.ts +14 -3
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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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={
|
| 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 {
|
| 287 |
-
const initialSrc = primary || fallback;
|
| 288 |
|
| 289 |
return (
|
| 290 |
<DashboardAdCard
|
| 291 |
key={ad.id}
|
| 292 |
ad={ad}
|
| 293 |
-
initialImageSrc={
|
| 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,
|
| 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>(
|
| 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={
|
| 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 |
-
*
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
};
|