From 30baff79c0ac222573e7859e67a87cf9348a7bb6 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 10 Oct 2026 07:43:24 +0000 Subject: [PATCH] feat(ads): agentic-era HQ hero art, a safety retry, and no app icons as heroes Ad heroes now render at quality "high" with one house art direction (launch-grade 3D key art for the agentic AI era). When OpenAI's safety system refuses the copy-bearing prompt ("zombie love story" is blocked as violence at the output stage), retry once with a copy-free abstract prompt instead of falling back. og:image fallback skips icons/logos, so a pwamart app no longer gets its 512px icon stretched behind a banner. Co-Authored-By: Claude Opus 5.5 --- lib/ads/heroImage.ts | 88 +++++++++++++++++++++++++++++------- package.json | 2 +- tests/ads-hero-image.test.ts | 48 ++++++++++++++++++++ 3 files changed, 120 insertions(+), 18 deletions(-) create mode 100644 tests/ads-hero-image.test.ts diff --git a/lib/ads/heroImage.ts b/lib/ads/heroImage.ts index f5e9662..9f18ea4 100644 --- a/lib/ads/heroImage.ts +++ b/lib/ads/heroImage.ts @@ -63,39 +63,93 @@ async function isUsableImage(url: string): Promise { } } -function buildPrompt(brand: SiteBrand, copy: AdHeroCopy): string { +// One house look for every hero: launch-grade key art for the agentic AI era, +// so a network of very different sites still reads as one premium ad product. +const ART_DIRECTION = [ + "Art direction: flagship-launch quality, cinematic 3D render with photoreal materials,", + "volumetric light, subtle holographic interface glints and luminous data threads", + "suggesting AI agents at work, shallow depth of field, one crisp high-detail focal", + "subject, refined editorial composition.", +].join(" "); + +const FRAMING = [ + "Clear negative space along the bottom for an overlaid caption.", + "No text, no letters, no logos, no watermarks, no UI chrome. Landscape 3:2.", +].join(" "); + +export function buildPrompt(brand: SiteBrand, copy: AdHeroCopy): string { const subject = [brand.title, brand.description].filter(Boolean).join(" — "); return [ - `Advertising hero image for a display ad promoting: ${subject || brand.domain}.`, - `The ad's message is "${copy.headline}"${copy.body ? ` (${copy.body})` : ""}.`, - `On-brand palette built around background ${copy.bgColor} and accent ${copy.accentColor}.`, - "A single striking focal subject, photographic or richly illustrative, dramatic lighting,", - "scroll-stopping, with clear negative space toward the bottom for an overlaid caption.", - "No on-image text, no words, no logos, no watermarks, no UI chrome. Landscape 3:2.", + "Premium key art for a display ad, made for the agentic AI era.", + `Product: ${subject || brand.domain}.`, + `Ad message: "${copy.headline}"${copy.body ? ` (${copy.body})` : ""}.`, + ART_DIRECTION, + `Palette built around deep background ${copy.bgColor} and accent ${copy.accentColor}.`, + FRAMING, ].join(" "); } +// The retry when the safety system refuses the subject prompt. Advertiser copy +// trips it on harmless products ("zombie love story" reads as violence, and is +// blocked at the output stage), so this one carries no copy at all: just the +// domain, the palette and an abstract focal object. +export function buildSafePrompt(brand: SiteBrand, copy: AdHeroCopy): string { + return [ + "Premium key art for a display ad, made for the agentic AI era.", + `Product category: a web product (${brand.domain}).`, + ART_DIRECTION, + "The focal subject is an abstract sculptural object; calm and friendly in tone; no people.", + `Palette built around deep background ${copy.bgColor} and accent ${copy.accentColor}.`, + FRAMING, + ].join(" "); +} + +function isModerationBlock(err: unknown): boolean { + const e = err as { code?: string; message?: string } | null; + return e?.code === "moderation_blocked" || /safety system/i.test(e?.message ?? ""); +} + +async function generateOnce(openai: OpenAI, prompt: string): Promise { + const res = await openai.images.generate({ + model: IMAGE_MODEL, + prompt, + size: "1536x1024", + quality: "high", + n: 1, + }); + const b64 = res.data?.[0]?.b64_json; + return b64 ? Buffer.from(b64, "base64") : null; +} + async function generateAiHero( openai: OpenAI, brand: SiteBrand, copy: AdHeroCopy, ): Promise { try { - const res = await openai.images.generate({ - model: IMAGE_MODEL, - prompt: buildPrompt(brand, copy), - size: "1536x1024", - quality: "medium", - n: 1, - }); - const b64 = res.data?.[0]?.b64_json; - return b64 ? Buffer.from(b64, "base64") : null; + return await generateOnce(openai, buildPrompt(brand, copy)); + } catch (err) { + if (!isModerationBlock(err)) { + console.warn("[ads] hero image gen failed", err instanceof Error ? err.message : err); + return null; + } + console.warn("[ads] hero prompt refused by the safety system; retrying abstract"); + } + try { + return await generateOnce(openai, buildSafePrompt(brand, copy)); } catch (err) { console.warn("[ads] hero image gen failed", err instanceof Error ? err.message : err); return null; } } +// An app icon is not a share image: a 512px square logo stretched behind a +// banner is what an ad looked like when generation failed and og:image was the +// site's icon (pwamart's apps all point og:image at /icon-512.png). +export function looksLikeIcon(url: string): boolean { + return /(^|[\/_-])(icon|favicon|apple-touch|logo)[^\/]*\.(png|jpe?g|webp|svg|ico)(\?|$)/i.test(url); +} + // Heroes are server-generated ad art, not user uploads. The ad-assets bucket's // storage RLS only lets an authenticated user write under their own `${uid}/` // prefix, so a user-scoped client can't write to `heroes/` (it fails with "new @@ -137,7 +191,7 @@ export async function resolveAdHeroImage(args: { } } - if (brand.ogImage && (await isUsableImage(brand.ogImage))) { + if (brand.ogImage && !looksLikeIcon(brand.ogImage) && (await isUsableImage(brand.ogImage))) { return { url: brand.ogImage, source: "og" }; } diff --git a/package.json b/package.json index d2e3796..8bd04c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crawlproof", - "version": "0.3.4", + "version": "0.3.5", "bin": { "crawlproof": "./bin/crawlproof.mjs" }, diff --git a/tests/ads-hero-image.test.ts b/tests/ads-hero-image.test.ts new file mode 100644 index 0000000..8e27bc0 --- /dev/null +++ b/tests/ads-hero-image.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/onion", () => ({ smartFetch: vi.fn() })); +vi.mock("@/lib/supabase/service", () => ({ serviceClient: vi.fn() })); + +import { buildPrompt, buildSafePrompt, looksLikeIcon } from "@/lib/ads/heroImage"; +import type { SiteBrand } from "@/lib/ads/brand"; + +const brand = { + domain: "pwamart.com", + title: "Zombie AI: install the web app", + description: "Create your own AI zombie love story with two photos.", +} as SiteBrand; +const copy = { headline: "Make a zombie love story", body: "Two photos in.", bgColor: "#101814", accentColor: "#e2582f" }; + +describe("ad hero prompts", () => { + it("carries the product, the copy, the palette and the agentic-era art direction", () => { + const p = buildPrompt(brand, copy); + expect(p).toContain("agentic AI era"); + expect(p).toContain("Zombie AI"); + expect(p).toContain("Make a zombie love story"); + expect(p).toContain("#101814"); + expect(p).toContain("#e2582f"); + }); + + it("drops every word of advertiser copy from the safety retry", () => { + const p = buildSafePrompt(brand, copy); + expect(p).not.toMatch(/zombie/i); + expect(p).toContain("pwamart.com"); + expect(p).toContain("agentic AI era"); + expect(p).toContain("#e2582f"); + }); +}); + +describe("looksLikeIcon", () => { + it("refuses app icons and logos as a hero", () => { + expect(looksLikeIcon("https://pwamart.com/icon-512.png")).toBe(true); + expect(looksLikeIcon("https://x.com/favicon.ico")).toBe(true); + expect(looksLikeIcon("https://x.com/apple-touch-icon.png")).toBe(true); + expect(looksLikeIcon("https://x.com/static/logo.svg?v=2")).toBe(true); + }); + + it("keeps real share images", () => { + expect(looksLikeIcon("https://x.com/og.png")).toBe(false); + expect(looksLikeIcon("https://x.com/images/hero-launch.jpg")).toBe(false); + expect(looksLikeIcon("https://x.com/blog/iconic-design/cover.png")).toBe(false); + }); +});