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",