fix: Meta previews vertical by default; show all Google ad formats
Meta cards use a 9:16 frame (carousel 1:1 then 4:5). Google gallery balances Search/PMax/Display and surfaces PMax multi-format thumbs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { googleAdsConfigured } from '@/lib/ads-discovery';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import type {
|
||||
AdCreativeCard,
|
||||
CreativeAspect,
|
||||
CreativeAsset,
|
||||
GoogleCreativeChannel,
|
||||
} from '@/lib/ad-creatives-meta';
|
||||
@@ -208,17 +209,80 @@ function ytThumb(youtubeId: string): string {
|
||||
return `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`;
|
||||
}
|
||||
|
||||
function googleAspectFromFieldType(ft: string): CreativeAspect {
|
||||
const u = ft.toUpperCase();
|
||||
if (u.includes('PORTRAIT') || u.includes('TALL')) return '4:5';
|
||||
if (u.includes('SQUARE') || u === 'LOGO') return '1:1';
|
||||
if (u.includes('YOUTUBE') || u.includes('LANDSCAPE') || u === 'MARKETING_IMAGE') {
|
||||
return '16:9';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function pickVerticalGoogle(assets: CreativeAsset[]): string {
|
||||
const images = assets.filter((a) => a.kind === 'image' && a.url);
|
||||
const portrait = images.find((a) =>
|
||||
/PORTRAIT|TALL|VERTICAL/i.test(a.label || '')
|
||||
const portrait = images.find(
|
||||
(a) =>
|
||||
a.aspect === '4:5' ||
|
||||
a.aspect === '9:16' ||
|
||||
/PORTRAIT|TALL|VERTICAL/i.test(a.label || '')
|
||||
);
|
||||
if (portrait) return portrait.url;
|
||||
const square = images.find((a) => /SQUARE/i.test(a.label || ''));
|
||||
const square = images.find(
|
||||
(a) => a.aspect === '1:1' || /SQUARE|LOGO/i.test(a.label || '')
|
||||
);
|
||||
if (square) return square.url;
|
||||
return images[0]?.url || '';
|
||||
}
|
||||
|
||||
function withAspect(asset: CreativeAsset, ft: string): CreativeAsset {
|
||||
if (asset.aspect && asset.aspect !== 'unknown') return asset;
|
||||
return { ...asset, aspect: googleAspectFromFieldType(ft) };
|
||||
}
|
||||
|
||||
/** Keep Search / PMax / Display represented in the gallery, not only top spend. */
|
||||
function balanceGoogleCards(cards: AdCreativeCard[], limit: number): AdCreativeCard[] {
|
||||
const buckets: Record<string, AdCreativeCard[]> = {
|
||||
search_brand: [],
|
||||
search_generic: [],
|
||||
pmax: [],
|
||||
display: [],
|
||||
other: [],
|
||||
};
|
||||
for (const c of cards) {
|
||||
const key = c.channel && buckets[c.channel] ? c.channel : 'other';
|
||||
buckets[key].push(c);
|
||||
}
|
||||
for (const k of Object.keys(buckets)) {
|
||||
buckets[k].sort((a, b) => b.spend - a.spend);
|
||||
}
|
||||
const quotas: Record<string, number> = {
|
||||
search_brand: Math.max(8, Math.floor(limit * 0.2)),
|
||||
search_generic: Math.max(8, Math.floor(limit * 0.2)),
|
||||
pmax: Math.max(12, Math.floor(limit * 0.35)),
|
||||
display: Math.max(8, Math.floor(limit * 0.2)),
|
||||
other: Math.max(4, Math.floor(limit * 0.1)),
|
||||
};
|
||||
const picked: AdCreativeCard[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [key, quota] of Object.entries(quotas)) {
|
||||
for (const c of buckets[key].slice(0, quota)) {
|
||||
if (seen.has(c.adId)) continue;
|
||||
seen.add(c.adId);
|
||||
picked.push(c);
|
||||
}
|
||||
}
|
||||
// Fill remaining slots by spend across leftovers
|
||||
const leftovers = cards
|
||||
.filter((c) => !seen.has(c.adId))
|
||||
.sort((a, b) => b.spend - a.spend);
|
||||
for (const c of leftovers) {
|
||||
if (picked.length >= limit) break;
|
||||
picked.push(c);
|
||||
}
|
||||
return picked.sort((a, b) => b.spend - a.spend).slice(0, limit);
|
||||
}
|
||||
|
||||
type AgBucket = {
|
||||
adId: string;
|
||||
adName: string;
|
||||
@@ -455,11 +519,16 @@ LIMIT 400
|
||||
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
|
||||
const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle);
|
||||
if (img && !list.some((a) => a.url === img)) {
|
||||
list.push({
|
||||
kind: ft.includes('LOGO') ? 'logo' : 'image',
|
||||
url: img,
|
||||
label: ft || 'MARKETING_IMAGE',
|
||||
});
|
||||
list.push(
|
||||
withAspect(
|
||||
{
|
||||
kind: ft.includes('LOGO') ? 'logo' : 'image',
|
||||
url: img,
|
||||
label: ft || 'MARKETING_IMAGE',
|
||||
},
|
||||
ft || 'MARKETING_IMAGE'
|
||||
)
|
||||
);
|
||||
}
|
||||
if (yt && !list.some((a) => a.youtubeId === yt)) {
|
||||
list.push({
|
||||
@@ -468,6 +537,7 @@ LIMIT 400
|
||||
label: 'YOUTUBE_VIDEO',
|
||||
youtubeId: yt,
|
||||
title: ytTitle || yt,
|
||||
aspect: '16:9',
|
||||
});
|
||||
}
|
||||
assetsByAdId.set(adId, list);
|
||||
@@ -560,11 +630,15 @@ LIMIT 400
|
||||
else if (ft === 'CALL_TO_ACTION_SELECTION' && cta) entry.ctas.push(cta);
|
||||
else if (ft.includes('MARKETING_IMAGE') && img) {
|
||||
if (!entry.assets.some((a) => a.url === img)) {
|
||||
entry.assets.push({ kind: 'image', url: img, label: ft });
|
||||
entry.assets.push(
|
||||
withAspect({ kind: 'image', url: img, label: ft }, ft)
|
||||
);
|
||||
}
|
||||
} else if ((ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') && img) {
|
||||
if (!entry.assets.some((a) => a.url === img)) {
|
||||
entry.assets.push({ kind: 'logo', url: img, label: ft });
|
||||
entry.assets.push(
|
||||
withAspect({ kind: 'logo', url: img, label: ft }, ft)
|
||||
);
|
||||
}
|
||||
} else if (ft === 'YOUTUBE_VIDEO' && yt) {
|
||||
if (!entry.assets.some((a) => a.youtubeId === yt)) {
|
||||
@@ -574,6 +648,7 @@ LIMIT 400
|
||||
label: 'YOUTUBE_VIDEO',
|
||||
youtubeId: yt,
|
||||
title: ytTitle || yt,
|
||||
aspect: '16:9',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -639,12 +714,6 @@ LIMIT 400
|
||||
const adId = str(ad?.id);
|
||||
if (!adId) continue;
|
||||
const m = adMetricMap.get(adId);
|
||||
const spend = m?.spend ?? 0;
|
||||
const impressions = m?.impressions ?? 0;
|
||||
const clicks = m?.clicks ?? 0;
|
||||
const conversions = m?.conversions ?? 0;
|
||||
if (!m && cards.length >= limit) continue;
|
||||
|
||||
const rsa = ad?.responsiveSearchAd;
|
||||
const rda = ad?.responsiveDisplayAd;
|
||||
const headlines = uniq([
|
||||
@@ -660,7 +729,12 @@ LIMIT 400
|
||||
const linkedAssets = assetsByAdId.get(adId) || [];
|
||||
const assets: CreativeAsset[] = [...linkedAssets];
|
||||
if (imageUrl && !assets.some((a) => a.url === imageUrl)) {
|
||||
assets.push({ kind: 'image', url: imageUrl, label: 'IMAGE_AD' });
|
||||
assets.push({
|
||||
kind: 'image',
|
||||
url: imageUrl,
|
||||
label: 'IMAGE_AD',
|
||||
aspect: '1:1',
|
||||
});
|
||||
}
|
||||
const adType = enumName(ad?.type).toLowerCase() || 'rsa';
|
||||
const mediaType = rsa
|
||||
@@ -674,6 +748,31 @@ LIMIT 400
|
||||
const channelType = enumName(row.campaign?.advertisingChannelType);
|
||||
const campaignName = str(row.campaign?.name);
|
||||
const adsetName = str(row.adGroup?.name);
|
||||
const campaignId = str(row.campaign?.id);
|
||||
// ad_daily is often campaign-level for Google → fallback so Search/Display appear
|
||||
const camp =
|
||||
campaignSpend.get(campaignId) ||
|
||||
campaignSpend.get(campaignName.toLowerCase());
|
||||
const spend = m?.spend ?? camp?.spend ?? 0;
|
||||
const impressions = m?.impressions ?? camp?.impressions ?? 0;
|
||||
const clicks = m?.clicks ?? camp?.clicks ?? 0;
|
||||
const conversions = m?.conversions ?? camp?.conversions ?? 0;
|
||||
const hasCreative =
|
||||
headlines.length > 0 ||
|
||||
descriptions.length > 0 ||
|
||||
assets.length > 0 ||
|
||||
Boolean(rsa) ||
|
||||
Boolean(rda);
|
||||
if (
|
||||
spend <= 0 &&
|
||||
impressions <= 0 &&
|
||||
clicks <= 0 &&
|
||||
!m &&
|
||||
!(hasCreative && camp && (camp.spend > 0 || camp.impressions > 0))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const preview =
|
||||
pickVerticalGoogle(assets) ||
|
||||
assets.find((a) => a.kind === 'image')?.url ||
|
||||
@@ -722,13 +821,17 @@ LIMIT 400
|
||||
}
|
||||
}
|
||||
|
||||
cards.sort((a, b) => b.spend - a.spend);
|
||||
const sliced = cards
|
||||
.filter(
|
||||
(c) =>
|
||||
c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0
|
||||
)
|
||||
.slice(0, limit);
|
||||
const active = cards.filter(
|
||||
(c) =>
|
||||
c.spend > 0 ||
|
||||
c.impressions > 0 ||
|
||||
c.clicks > 0 ||
|
||||
c.conversions > 0 ||
|
||||
(c.mediaType === 'rsa' && (c.headlines?.length || 0) > 0) ||
|
||||
((c.mediaType === 'rda' || c.mediaType === 'image') &&
|
||||
(c.assets?.length || 0) > 0)
|
||||
);
|
||||
const sliced = balanceGoogleCards(active, limit);
|
||||
|
||||
if (!sliced.length) {
|
||||
return warehouseFallback(
|
||||
|
||||
@@ -105,12 +105,7 @@ export function inferCreativeAspect(
|
||||
height?: number
|
||||
): CreativeAspect {
|
||||
const L = (label || '').toLowerCase();
|
||||
if (/9\s*:?\s*16|story|reels|vertical|tall|portrait_9/.test(L)) return '9:16';
|
||||
if (/4\s*:?\s*5|portrait|feed_portrait/.test(L)) return '4:5';
|
||||
if (/16\s*:?\s*9|1\.91|landscape|horizontal|right_column|video/.test(L)) {
|
||||
return '16:9';
|
||||
}
|
||||
if (/1\s*:?\s*1|square/.test(L)) return '1:1';
|
||||
// Dimensiones primero: labels tipo "video" no deben forzar landscape.
|
||||
if (width && height && width > 0 && height > 0) {
|
||||
const r = width / height;
|
||||
if (r <= 0.62) return '9:16';
|
||||
@@ -118,6 +113,12 @@ export function inferCreativeAspect(
|
||||
if (r >= 1.5) return '16:9';
|
||||
if (Math.abs(r - 1) < 0.12) return '1:1';
|
||||
}
|
||||
if (/9\s*:?\s*16|story|reels|vertical|tall|portrait_9/.test(L)) return '9:16';
|
||||
if (/4\s*:?\s*5|portrait|feed_portrait/.test(L)) return '4:5';
|
||||
if (/16\s*:?\s*9|1\.91|landscape|horizontal|right_column|youtube/.test(L)) {
|
||||
return '16:9';
|
||||
}
|
||||
if (/1\s*:?\s*1|square/.test(L)) return '1:1';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -201,7 +202,8 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
||||
|
||||
if (Array.isArray(link?.child_attachments)) {
|
||||
(link!.child_attachments as Record<string, unknown>[]).forEach((c, i) => {
|
||||
pushImg(c.picture || c.image_url, `carousel-${i + 1} 4:5`, 1080, 1350);
|
||||
// Carrusel: preferimos 1:1; si el asset es más alto, se reclasifica por ratio.
|
||||
pushImg(c.picture || c.image_url, `carousel-${i + 1} 1:1`, 1080, 1080);
|
||||
pushHash(c.image_hash);
|
||||
if (c.name) headlines.push(str(c.name));
|
||||
if (c.message) bodies.push(str(c.message));
|
||||
@@ -269,22 +271,56 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
||||
};
|
||||
}
|
||||
|
||||
/** Prefer 9:16 story; then 4:5; else first image. */
|
||||
function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string {
|
||||
const images = assets.filter((a) => a.kind === 'image' && a.url);
|
||||
const nine = images.find((a) => a.aspect === '9:16');
|
||||
if (nine) return nine.url;
|
||||
const portrait = images.find(
|
||||
/**
|
||||
* Meta preview picker:
|
||||
* - Default: vertical 9:16 (vídeo/imagen), then 4:5
|
||||
* - Carousel: 1:1, then 4:5
|
||||
*/
|
||||
export function pickMetaPreviewAsset(
|
||||
assets: CreativeAsset[],
|
||||
mediaType: string,
|
||||
fallbackUrl = ''
|
||||
): { url: string; aspect: CreativeAspect } {
|
||||
const withUrl = assets.filter((a) => a.url);
|
||||
const aspectOf = (a: CreativeAsset) =>
|
||||
a.aspect && a.aspect !== 'unknown'
|
||||
? a.aspect
|
||||
: inferCreativeAspect(a.label);
|
||||
|
||||
if (mediaType === 'carousel') {
|
||||
const square = withUrl.find((a) => aspectOf(a) === '1:1');
|
||||
if (square) return { url: square.url, aspect: '1:1' };
|
||||
const fourFive = withUrl.find((a) => aspectOf(a) === '4:5');
|
||||
if (fourFive) return { url: fourFive.url, aspect: '4:5' };
|
||||
const carousel = withUrl.find((a) => /carousel/i.test(a.label || ''));
|
||||
if (carousel) return { url: carousel.url, aspect: '1:1' };
|
||||
if (withUrl[0]) return { url: withUrl[0].url, aspect: '1:1' };
|
||||
return { url: fallbackUrl, aspect: '1:1' };
|
||||
}
|
||||
|
||||
// Prefer video poster/frame labeled 9:16, then any 9:16 image, then 4:5
|
||||
const videoNine = withUrl.find(
|
||||
(a) =>
|
||||
a.aspect === '4:5' ||
|
||||
/portrait|vertical|9.?16|4.?5|tall|story/i.test(a.label || '')
|
||||
(a.kind === 'video' || /video|reel|story/i.test(a.label || '')) &&
|
||||
aspectOf(a) === '9:16'
|
||||
);
|
||||
if (portrait) return portrait.url;
|
||||
const nonSquare = images.find(
|
||||
(a) => a.label?.includes('thumbnail') || a.label?.includes('video_poster')
|
||||
if (videoNine) return { url: videoNine.url, aspect: '9:16' };
|
||||
|
||||
const nine = withUrl.find((a) => aspectOf(a) === '9:16');
|
||||
if (nine) return { url: nine.url, aspect: '9:16' };
|
||||
|
||||
const fourFive = withUrl.find((a) => aspectOf(a) === '4:5');
|
||||
if (fourFive) return { url: fourFive.url, aspect: '4:5' };
|
||||
|
||||
const thumb = withUrl.find((a) =>
|
||||
/thumbnail|video_poster|video_feed/i.test(a.label || '')
|
||||
);
|
||||
if (nonSquare) return nonSquare.url;
|
||||
return images[0]?.url || fallback;
|
||||
if (thumb) return { url: thumb.url, aspect: '9:16' };
|
||||
|
||||
if (withUrl[0]) {
|
||||
return { url: withUrl[0].url, aspect: aspectOf(withUrl[0]) || '9:16' };
|
||||
}
|
||||
return { url: fallbackUrl, aspect: '9:16' };
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
@@ -657,7 +693,8 @@ function cardFromBundle(
|
||||
const creative = b?.creative || {};
|
||||
const fallback =
|
||||
str(creative.image_url) || str(creative.thumbnail_url) || '';
|
||||
const preview = pickVerticalPreview(assets, fallback);
|
||||
const mediaType = detectMediaType(creative);
|
||||
const picked = pickMetaPreviewAsset(assets, mediaType, fallback);
|
||||
const headlines = pack.headlines;
|
||||
const bodies = pack.bodies;
|
||||
const descriptions = pack.descriptions;
|
||||
@@ -672,9 +709,9 @@ function cardFromBundle(
|
||||
clicks: agg.clicks,
|
||||
conversions: agg.conversions,
|
||||
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
||||
mediaType: detectMediaType(creative),
|
||||
thumbnailUrl: preview,
|
||||
imageUrl: preview,
|
||||
mediaType,
|
||||
thumbnailUrl: picked.url,
|
||||
imageUrl: picked.url,
|
||||
headline: headlines.join('\n'),
|
||||
primaryText: bodies.join('\n') || descriptions.join('\n'),
|
||||
headlines,
|
||||
|
||||
Reference in New Issue
Block a user