feat: usar as 3 imagens por posicionamento (placement customization)

Antes o criativo usava só a imagem quadrada. Agora, quando há >=2 imagens
geradas, monta o criativo com Placement Asset Customization: 1:1 no feed,
9:16 em stories/reels e 1.91:1 na coluna direita — em ad set normal
(não-dinâmico), com object_story_spec mantendo page_id/IG.

Cascata de fallback segura: se a Meta recusar a customização, refaz com a
imagem única (comportamento antigo estável); se recusar o IG, refaz só
com Facebook. Publicação nunca quebra por causa disso.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcio Bevervanso 2026-06-16 12:40:29 -03:00
parent f2d3f34bbf
commit 6068f77b09
2 changed files with 113 additions and 15 deletions

View file

@ -281,8 +281,68 @@ export async function callMetaTool(
case 'create_ad_creative': case 'create_ad_creative':
requireFields(args, ['name', 'page_id', 'link', 'message'], toolName); requireFields(args, ['name', 'page_id', 'link', 'message'], toolName);
if (args.image_hashes && Array.isArray(args.image_hashes) && args.image_hashes.length > 0) { // Placement Asset Customization: imagem específica por posicionamento
// (1:1 no feed, 9:16 em stories/reels, 1.91:1 na coluna direita). Roda
// em ad set normal (não-dinâmico). Mantém object_story_spec com page_id
// (e IG) + asset_feed_spec com regras de customização por placement.
if (args.placement_images && (args.placement_images.vertical || args.placement_images.horizontal)) {
const pi = args.placement_images as { square?: string; vertical?: string; horizontal?: string };
// O branch garante que ao menos vertical/horizontal existe.
const squareHash = (pi.square || pi.vertical || pi.horizontal) as string;
const images: Array<{ hash: string; adlabels: Array<{ name: string }> }> = [
{ hash: squareHash, adlabels: [{ name: 'sq' }] },
];
const rules: Array<Record<string, unknown>> = [
// Regra padrão (mais ampla): cobre todos os posicionamentos.
{
customization_spec: { publisher_platforms: ['facebook', 'instagram', 'audience_network', 'messenger'] },
image_label: { name: 'sq' },
},
];
if (pi.vertical && pi.vertical !== squareHash) {
images.push({ hash: pi.vertical, adlabels: [{ name: 'vt' }] });
rules.push({
customization_spec: {
publisher_platforms: ['facebook', 'instagram'],
facebook_positions: ['story', 'facebook_reels'],
instagram_positions: ['story', 'reels'],
},
image_label: { name: 'vt' },
});
}
if (pi.horizontal && pi.horizontal !== squareHash) {
images.push({ hash: pi.horizontal, adlabels: [{ name: 'hz' }] });
rules.push({
customization_spec: {
publisher_platforms: ['facebook'],
facebook_positions: ['right_hand_column'],
},
image_label: { name: 'hz' },
});
}
const objectStorySpec: any = { page_id: args.page_id };
if (args.instagram_actor_id) objectStorySpec.instagram_actor_id = args.instagram_actor_id;
resultData = await fetchGraphPostForm(`/${accountId}/adcreatives`, token, {
name: args.name,
object_story_spec: objectStorySpec,
asset_feed_spec: {
images,
bodies: [{ text: args.message }],
titles: [{ text: args.headline || 'Headline' }],
descriptions: [{ text: args.description || '' }],
link_urls: [{ website_url: args.link }],
call_to_action_types: [args.call_to_action_type || 'LEARN_MORE'],
ad_formats: ['SINGLE_IMAGE'],
asset_customization_rules: rules,
},
});
} else if (args.image_hashes && Array.isArray(args.image_hashes) && args.image_hashes.length > 0) {
// Asset Customization / Dynamic Creative // Asset Customization / Dynamic Creative
const assetFeedSpec = { const assetFeedSpec = {
images: args.image_hashes.map((hash: any) => ({ hash })), images: args.image_hashes.map((hash: any) => ({ hash })),

View file

@ -474,6 +474,12 @@ export async function createAd(
return extractContent(result); return extractContent(result);
} }
export interface PlacementImages {
square?: string;
vertical?: string;
horizontal?: string;
}
export async function createAdCreative( export async function createAdCreative(
client: Client, client: Client,
params: { params: {
@ -482,6 +488,9 @@ export async function createAdCreative(
instagram_actor_id?: string; instagram_actor_id?: string;
image_hash?: string; image_hash?: string;
image_hashes?: string[]; image_hashes?: string[];
// Imagens por posicionamento (1:1 / 9:16 / 1.91:1). Quando presente, o
// criativo usa Placement Asset Customization em vez de uma imagem única.
placement_images?: PlacementImages;
link: string; link: string;
message: string; message: string;
headline: string; headline: string;
@ -496,6 +505,7 @@ export async function createAdCreative(
instagram_actor_id: params.instagram_actor_id, instagram_actor_id: params.instagram_actor_id,
image_hash: params.image_hash, image_hash: params.image_hash,
image_hashes: params.image_hashes, image_hashes: params.image_hashes,
placement_images: params.placement_images,
link: params.link, link: params.link,
message: params.message, message: params.message,
headline: params.headline, headline: params.headline,
@ -626,19 +636,19 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
const creativeName = `${params.ad_name} — Creative`; const creativeName = `${params.ad_name} — Creative`;
// Use a single primary image via the standard object_story_spec path — // A imagem quadrada (1080x1080, a primeira gerada) é a mais compatível e vira
// asset_feed_spec (Asset Customization / Dynamic Creative) requires the ad // o fallback universal. Quando há ≥2 imagens, tentamos também usar cada uma no
// set to be created with is_dynamic_creative=true, which we don't set here, // seu posicionamento (1:1 feed / 9:16 stories-reels / 1.91:1 coluna direita)
// and combining it with degrees_of_freedom_spec triggers Meta error subcode // via Placement Asset Customization (roda em ad set normal, não-dinâmico).
// 2490433 ("unexpected error"). The first generated image (1080x1080) is
// the most broadly compatible across placements.
const primaryImageHash = imageHashes[0]; const primaryImageHash = imageHashes[0];
const placementImages: PlacementImages | undefined =
!params.is_whatsapp && imageHashes.length >= 2
? { square: imageHashes[0], vertical: imageHashes[1], horizontal: imageHashes[2] }
: undefined;
// Monta o criativo com (ou sem) o Instagram. Quando a conta de anúncios não // Monta o criativo com (ou sem) o Instagram e com (ou sem) customização por
// tem permissão pra usar a conta de IG vinculada à página, a Meta recusa o // posicionamento — cada um tem seu próprio fallback (ver cascata abaixo).
// criativo inteiro (erro 200 / subcode 1815199). Nesse caso refazemos o const buildCreative = (instagramActorId?: string, usePlacements = false) => {
// criativo só com o Facebook, em vez de barrar a publicação.
const buildCreative = (instagramActorId?: string) => {
if (params.is_whatsapp) { if (params.is_whatsapp) {
return createWhatsappAdCreative(client, { return createWhatsappAdCreative(client, {
name: creativeName, name: creativeName,
@ -659,6 +669,7 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
page_id: params.page_id, page_id: params.page_id,
instagram_actor_id: instagramActorId, instagram_actor_id: instagramActorId,
image_hash: primaryImageHash, image_hash: primaryImageHash,
placement_images: usePlacements ? placementImages : undefined,
link: params.link, link: params.link,
message: params.primary_text, message: params.primary_text,
headline: params.headline, headline: params.headline,
@ -680,14 +691,40 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
/instagram_actor_id/i.test(err.message) || /instagram_actor_id/i.test(err.message) ||
/valid instagram account/i.test(err.message)); /valid instagram account/i.test(err.message));
const wantPlacements = !!placementImages;
let creative; let creative;
let instagramDropped = false; let instagramDropped = false;
let placementsDropped = false;
try { try {
creative = await buildCreative(params.instagram_actor_id); creative = await buildCreative(params.instagram_actor_id, wantPlacements);
} catch (err) { } catch (err) {
if (params.instagram_actor_id && isInstagramError(err)) { if (params.instagram_actor_id && isInstagramError(err)) {
creative = await buildCreative(undefined); // Problema no Instagram: refaz sem IG (mantendo a customização se houver).
instagramDropped = true; instagramDropped = true;
try {
creative = await buildCreative(undefined, wantPlacements);
} catch (err2) {
if (wantPlacements) {
placementsDropped = true;
creative = await buildCreative(undefined, false);
} else {
throw err2;
}
}
} else if (wantPlacements) {
// Erro provável da customização por posicionamento → cai pra imagem única.
placementsDropped = true;
try {
creative = await buildCreative(params.instagram_actor_id, false);
} catch (err2) {
if (params.instagram_actor_id && isInstagramError(err2)) {
instagramDropped = true;
creative = await buildCreative(undefined, false);
} else {
throw err2;
}
}
} else { } else {
throw err; throw err;
} }
@ -715,6 +752,7 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
creative_id: creativeId, creative_id: creativeId,
ad_id: adId, ad_id: adId,
instagram_dropped: instagramDropped, instagram_dropped: instagramDropped,
placements_dropped: placementsDropped,
}; };
} }