diff --git a/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx b/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx
index 272852b2..476f9785 100644
--- a/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx
+++ b/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx
@@ -7,6 +7,7 @@ import { DESIGN_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/
import { AdPreview } from "@/components/ads/ad-preview";
import { ColorField } from "@/components/ads/color-field";
import type { AdTheme } from "@/lib/ads/theme";
+import { originalHeroPngDownloadUrl } from "@/lib/ads/heroImageUrl";
type Campaign = {
id: string;
@@ -44,6 +45,7 @@ export function EditCampaignForm({
const [uploading, setUploading] = useState(false);
const current = creatives.find((c) => c.format === active) ?? creatives[0];
+ const originalHeroDownload = originalHeroPngDownloadUrl(current?.imageUrl);
// What the pickers show: the stored trio for this theme, or the derived one
// when the creative predates theme variants and has no light trio yet.
const palette = current
@@ -261,6 +263,11 @@ export function EditCampaignForm({
Remove image
)}
+ {originalHeroDownload && (
+
+ Download original PNG
+
+ )}
)}
diff --git a/app/(app)/dashboard/ads/new/form.tsx b/app/(app)/dashboard/ads/new/form.tsx
index 89abb32c..28963a31 100644
--- a/app/(app)/dashboard/ads/new/form.tsx
+++ b/app/(app)/dashboard/ads/new/form.tsx
@@ -8,6 +8,7 @@ import type { SiteBrand } from "@/lib/ads/brand";
import { AdPreview } from "@/components/ads/ad-preview";
import { ColorField } from "@/components/ads/color-field";
import type { AdTheme } from "@/lib/ads/theme";
+import { originalHeroPngDownloadUrl } from "@/lib/ads/heroImageUrl";
function dollars(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
@@ -37,6 +38,7 @@ export function NewAdForm() {
const [theme, setTheme] = useState("dark");
const current = creatives.find((c) => c.format === active) ?? creatives[0];
+ const originalHeroDownload = originalHeroPngDownloadUrl(current?.imageUrl);
const palette = current
? paletteFor(current, theme)
: { bgColor: "#0b0d10", fgColor: "#e7e9ee", accentColor: "#6ee7b7" };
@@ -357,6 +359,11 @@ export function NewAdForm() {
Image fills the Medium Rectangle background
+ {originalHeroDownload && (
+
+ Download original PNG
+
+ )}
diff --git a/bun.lock b/bun.lock
index 45e2bea9..af47ad3a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -44,6 +44,7 @@
"react-globe.gl": "^2.38.0",
"recharts": "^3.10.1",
"resend": "^6.34.1",
+ "sharp": "^0.35.5",
"socks": "^2.8.10",
"ssh2": "1.16.0",
"stripe": "^23.0.1",
@@ -678,7 +679,7 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
- "detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
@@ -1240,7 +1241,7 @@
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
- "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+ "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
"linkinator/htmlparser2": ["htmlparser2@12.0.0", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="],
@@ -1252,12 +1253,8 @@
"next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
- "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
-
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
- "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
-
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
"type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
@@ -1278,8 +1275,6 @@
"linkinator/htmlparser2/entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
- "vite/lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
-
"vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
diff --git a/lib/ads/heroImage.ts b/lib/ads/heroImage.ts
index 9f18ea41..acf380d9 100644
--- a/lib/ads/heroImage.ts
+++ b/lib/ads/heroImage.ts
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import type OpenAI from "openai";
+import sharp from "sharp";
import type { SupabaseClient } from "@supabase/supabase-js";
import { smartFetch } from "@/lib/onion";
import { serviceClient } from "@/lib/supabase/service";
@@ -19,6 +20,8 @@ const ASSET_BUCKET = "ad-assets";
const IMAGE_MODEL = "gpt-image-2.5-sunburst";
const VALIDATE_TIMEOUT_MS = 4000;
const MIN_IMAGE_BYTES = 2000; // <2KB is almost certainly a tracking pixel/placeholder
+const HERO_RENDITION_WIDTH = 600;
+const HERO_RENDITION_HEIGHT = 400;
export type AdHeroCopy = {
headline: string;
@@ -157,16 +160,47 @@ export function looksLikeIcon(url: string): boolean {
// which bypasses RLS.
async function uploadHero(bytes: Buffer): Promise {
const svc = serviceClient();
- const path = `heroes/${crypto.randomUUID()}.png`;
- const { error } = await svc.storage.from(ASSET_BUCKET).upload(path, bytes, {
+ const id = crypto.randomUUID();
+ const originalPath = `heroes/${id}.png`;
+ const renditionPath = `heroes/${id}.webp`;
+ let rendition: Buffer;
+ try {
+ rendition = await sharp(bytes)
+ .rotate()
+ .resize({
+ width: HERO_RENDITION_WIDTH,
+ height: HERO_RENDITION_HEIGHT,
+ fit: "inside",
+ withoutEnlargement: true,
+ })
+ .webp({ quality: 82, effort: 5 })
+ .toBuffer();
+ } catch (error) {
+ console.warn("[ads] hero rendition failed", error instanceof Error ? error.message : error);
+ return null;
+ }
+
+ const storage = svc.storage.from(ASSET_BUCKET);
+ const { error: originalError } = await storage.upload(originalPath, bytes, {
contentType: "image/png",
upsert: false,
});
- if (error) {
- console.warn("[ads] hero upload failed", error.message);
+ if (originalError) {
+ console.warn("[ads] hero original upload failed", originalError.message);
+ return null;
+ }
+
+ const { error: renditionError } = await storage.upload(renditionPath, rendition, {
+ contentType: "image/webp",
+ upsert: false,
+ });
+ if (renditionError) {
+ console.warn("[ads] hero rendition upload failed", renditionError.message);
+ await storage.remove([originalPath]).catch(() => {});
return null;
}
- return svc.storage.from(ASSET_BUCKET).getPublicUrl(path).data.publicUrl;
+
+ return storage.getPublicUrl(renditionPath).data.publicUrl;
}
// Resolve the hero image URL for a set of ad creatives. A gpt-image-2.5 hero
diff --git a/lib/ads/heroImageUrl.ts b/lib/ads/heroImageUrl.ts
new file mode 100644
index 00000000..e5fdd31c
--- /dev/null
+++ b/lib/ads/heroImageUrl.ts
@@ -0,0 +1,21 @@
+/**
+ * AI-generated hero renditions and their original PNG share a UUID in the
+ * public ad-assets bucket. Keep the original available as an advertiser download.
+ */
+export function originalHeroPngDownloadUrl(imageUrl: string | null | undefined): string | null {
+ if (!imageUrl) return null;
+ try {
+ const url = new URL(imageUrl);
+ if (url.protocol !== "https:") return null;
+ const match = url.pathname.match(
+ /^\/storage\/v1\/object\/public\/ad-assets\/heroes\/([0-9a-f-]+)\.webp$/i,
+ );
+ if (!match) return null;
+ url.pathname = `/storage/v1/object/public/ad-assets/heroes/${match[1]}.png`;
+ url.search = "";
+ url.searchParams.set("download", "hero.png");
+ return url.toString();
+ } catch {
+ return null;
+ }
+}
diff --git a/package.json b/package.json
index b514bd1a..28237223 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"react-globe.gl": "^2.38.0",
"recharts": "^3.10.1",
"resend": "^6.34.1",
+ "sharp": "^0.35.5",
"socks": "^2.8.10",
"ssh2": "1.16.0",
"stripe": "^23.0.1",